From 8db6c77221820945eeb087d67f83f97bf0386880 Mon Sep 17 00:00:00 2001 From: Valentin MILLET Date: Sat, 8 Aug 2026 15:23:39 +0200 Subject: [PATCH 1/6] Integrate automated TypeScript bindings generation with tauri-specta, replace manual `IpcContract` definitions, enhance type safety for cross-layer communication, and refactor invocation error handling logic. --- .prettierignore | 3 + CLAUDE.md | 15 +- docs/architecture.md | 147 ++++++---- eslint.config.mjs | 3 + package.json | 3 +- src-tauri/Cargo.lock | 98 +++++++ src-tauri/Cargo.toml | 35 +-- src-tauri/src/bin/export-bindings.rs | 10 + src-tauri/src/commands/error.rs | 11 +- src-tauri/src/commands/notes.rs | 4 + src-tauri/src/commands/spaces.rs | 8 +- src-tauri/src/commands/tray.rs | 4 +- src-tauri/src/domain/note.rs | 26 +- src-tauri/src/domain/space.rs | 5 +- src-tauri/src/domain/view.rs | 21 +- src-tauri/src/lib.rs | 61 ++++- src/app/core/app-info/app-info.service.ts | 4 +- src/app/core/ipc/app-events.service.ts | 4 +- src/app/core/ipc/bindings.ts | 250 ++++++++++++++++++ ...{ipc.service.spec.ts => ipc.error.spec.ts} | 32 ++- src/app/core/ipc/ipc.error.ts | 91 ++++--- src/app/core/ipc/ipc.service.ts | 73 ----- src/app/core/tray/tray.service.spec.ts | 24 +- src/app/core/tray/tray.service.ts | 17 +- src/app/core/updates/updater.service.ts | 5 +- src/app/features/notes/data/note.dto.ts | 134 ++++------ .../features/notes/data/note.dto.view.spec.ts | 5 +- .../features/notes/data/notes.repository.ts | 19 +- .../features/notes/data/spaces.repository.ts | 15 +- 29 files changed, 752 insertions(+), 375 deletions(-) create mode 100644 src-tauri/src/bin/export-bindings.rs create mode 100644 src/app/core/ipc/bindings.ts rename src/app/core/ipc/{ipc.service.spec.ts => ipc.error.spec.ts} (70%) delete mode 100644 src/app/core/ipc/ipc.service.ts diff --git a/.prettierignore b/.prettierignore index 5bc63bd..b834b47 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,9 @@ coverage src-tauri/target # Schémas générés par Tauri au build. src-tauri/gen +# Bindings générés par tauri-specta : réécrits à chaque build Rust, les reformater +# ferait diverger le fichier de ce que le générateur produit. +src/app/core/ipc/bindings.ts package-lock.json # Maquette statique de référence : conservée telle quelle, hors périmètre du formatage. docs/scratch-mockup-v2.html diff --git a/CLAUDE.md b/CLAUDE.md index 973acdb..cd1383c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co DevBox — a desktop "Swiss Army knife" utility app for developers (notes-taking, hashing, encoding). Front-end is **Angular 22** (standalone components, signals, zoneless change detection), native engine is **Rust / Tauri v2**. -The **notes feature is complete end to end**: front-end (spaces with creation, renaming, deletion and filtering, search, filters, tag rail, sections, full note editing — content, format, tags, pin, deadline, move to another space, deletion), IPC (no mock data left, the Rust backend is the only data source, every read/write goes through `IpcService`), and Rust (`query_notes`, `create_note`, `update_note`, `delete_note`, `list_spaces`, `create_space`, `rename_space`, `delete_space` persist to an embedded SQLite database). There is no crypto or formatters module: they were removed once it became clear they would ship dead code. +The **notes feature is complete end to end**: front-end (spaces with creation, renaming, deletion and filtering, search, filters, tag rail, sections, full note editing — content, format, tags, pin, deadline, move to another space, deletion), IPC (no mock data left, the Rust backend is the only data source, every read/write goes through the tauri-specta bindings generated from the Rust signatures), and Rust (`query_notes`, `create_note`, `update_note`, `delete_note`, `list_spaces`, `create_space`, `rename_space`, `delete_space` persist to an embedded SQLite database). There is no crypto or formatters module: they were removed once it became clear they would ship dead code. **Data processing belongs to Rust.** Filtering (space, full-text, tags, languages, quick filters), grouping into sections, facet aggregation and tag normalisation, and the choice of what a card's footer shows all run in `src-tauri/src/domain/`. `query_notes` returns a ready-to-render `NotesView`; the front-end describes the query and displays the answer, it never filters, sorts or groups. Deliberate exceptions: relative-time **formatting** (labels must age without a round trip), the ISO ↔ `Date` conversion at the serialisation boundary, syntax highlighting (it colours the unsaved editor draft — a round trip per keystroke otherwise), and pure UI concerns (shortcuts, editor drafts, which space a new note goes to). @@ -39,18 +39,21 @@ Run all commands from the repo root (`package.json` there wraps both Angular and - `cargo test` from `src-tauri/` — persistence and serialisation tests (no extra setup; they run against an in-memory SQLite database). +- `npm run bindings` — regenerates `src/app/core/ipc/bindings.ts` from the Rust signatures without launching the app. `npm run tauri dev` does it too, at every launch. + - `cargo clippy -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` sets `unsafe_code = "forbid"` and `deny(clippy::all)`. ## Things that will bite you These are the non-obvious constraints; the rest of the architecture is in `docs/architecture.md`. -- **Registering commands.** A new `#[tauri::command]` must be added to `tauri::generate_handler![...]` in `src-tauri/src/lib.rs`, **and** to `IpcContract` in `src/app/core/ipc/ipc.service.ts`, or `invoke()` fails at runtime with "command not found". Tauri matches arguments by name, not position; `IpcContract` is what makes a wrong key a build error instead of a runtime serde rejection. Careful: Tauri v2 applies `rename_all = "camelCase"` to arguments, so a Rust `note_id` is `noteId` on the wire. -- **Serialisation contract.** JSON has no date type, so every `Date` crosses the bridge as an ISO string and is converted in `features/notes/data/note.dto.ts` — never type an `invoke()` result as a domain model directly. On the Rust side the `Note`, `NotesQuery` and `NotesView` structs need `#[serde(rename_all = "camelCase")]` (`spaceId`, `createdAt`, `availableTags`…) and the lifecycle enum `#[serde(tag = "kind", rename_all = "camelCase")]`, or the front-end cannot read what it receives. -- **Errors are codes, not strings.** Commands return `Result` (`commands/error.rs`): a stable `code`, its interpolation `params`, and a technical `detail`. Returning a `String` would put a French sentence in the English UI and force callers to parse prose. The code→key mapping lives in exactly one place, `core/errors/error-notifier.service.ts`, whose `Record` table fails the build until a new variant gets a key. `IpcErrorCode` mirrors `ErrorCode` — add a variant to both, plus the key in **both** locales. `IpcError.code` is `null` when Tauri itself rejects (unknown command, bad argument) _and_ when the code is unknown to this build, so handle that. +- **The IPC surface is generated.** `src/app/core/ipc/bindings.ts` comes from tauri-specta: one typed function per command plus a TS type per struct crossing the bridge. It is committed and regenerated by `npm run tauri dev` or `npm run bindings` (the `export-bindings` binary). Adding a command means annotating it `#[tauri::command]` **and** `#[specta::specta]`, adding it to `collect_commands![...]` in `src-tauri/src/lib.rs` — the single list, it both registers with Tauri and drives the generation — then regenerating. Every type crossing the bridge derives `specta::Type`. Specta refuses `usize`/`i64`/… (JSON precision), hence `NotesView.matched: u32`. The generator is _not_ wired as a `#[test]`: on Windows the test exe lives in `target/debug/deps/`, without the `WebView2Loader.dll` that linking `Builder::export` then needs, and the whole test binary fails to start. +- **Calls return a Result, not a rejection.** `commands.queryNotes(q)` gives `{ status: 'ok' | 'error' }`. Repositories run it through `unwrap()` (`core/ipc/ipc.error.ts`), which returns the data or throws an `IpcError` — stores and components keep their `try`/`catch`. Only `features/notes/data/` and `core/ipc/` import `bindings.ts`; everything else uses the DTO aliases re-exported from `note.dto.ts`. +- **Serialisation contract.** The camelCase and `tag = "kind"` serde attributes are still load-bearing, but specta reads them, so the TS side follows automatically. What generation does _not_ cover, and what `features/notes/data/note.dto.ts` still exists for: JSON has no date type (every `Date` crosses as an ISO string), `language` is a free `String` in Rust that the front narrows to a `LanguageTag`, and a patch omits the keys it does not touch (hence `#[specta(optional)]` on every `NotePatch` field — without it the generated type would demand explicit `null`s, which overwrite). +- **Errors are codes, not strings.** Commands return `Result` (`commands/error.rs`): a stable `code`, its interpolation `params`, and a technical `detail`. Returning a `String` would put a French sentence in the English UI and force callers to parse prose. `IpcErrorCode` is now a plain alias of the **generated** `ErrorCode`, so adding a Rust variant breaks two tables until it is handled: `CODE_KEYS` in `core/errors/error-notifier.service.ts` (which needs a key in **both** locales) and `IPC_ERROR_CODES` in `ipc.error.ts`. That second one is a runtime guard and still earns its keep: the bindings _declare_ the error branch as an `AppError`, but Tauri rejects with a plain string for an unknown command or a bad argument, and that lands in the same branch — `IpcError.code` is `null` there. - **Input is validated in the domain, not just in the form.** `domain/rules.rs`; commands call `draft.validate()` / `validated_name()` before locking. A rule held only by a form is not held. -- **Data-source seam.** Components and stores never touch a data source directly: everything goes through `NotesRepository` / `SpacesRepository`, plain `providedIn: 'root'` classes — no interface, no `InjectionToken`, nothing bound in `app.config.ts`, because there is exactly one implementation. Specs substitute them by class (`{ provide: NotesRepository, useValue: fake }`, via `provideAppTesting()`); the fakes in `src/testing/` keep their compile-time check with `implements Pick` (`keyof` drops the private `ipc`, which would make the class nominal). Don't call `invoke()` from a component or a store — `IpcService` is the only caller. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. -- **A DTO exists only where the wire shape differs from the domain shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: wire shape, repository, conversion. Notes need theirs (`Date` ↔ ISO, `string` → `LanguageTag`, section key, patch copied field by field) and it lives in `features/notes/data/note.dto.ts`. Spaces don't: `Space` crosses the bridge as itself, `IpcContract` types `list_spaces` with it directly. Don't reintroduce an identity DTO for symmetry. +- **Data-source seam.** Components and stores never touch a data source directly: everything goes through `NotesRepository` / `SpacesRepository`, plain `providedIn: 'root'` classes — no interface, no `InjectionToken`, nothing bound in `app.config.ts`, because there is exactly one implementation. Specs substitute them by class (`{ provide: NotesRepository, useValue: fake }`, via `provideAppTesting()`); the fakes in `src/testing/` keep their compile-time check with `implements Pick`. Don't call a generated command from a component or a store — the repositories are the only callers. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. +- **A conversion exists only where the wire shape differs from the domain shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: wire types (now generated aliases), repository, conversion. Notes need theirs (`Date` ↔ ISO, `string` → `LanguageTag`, patch copied field by field) and it lives in `features/notes/data/note.dto.ts`. Spaces don't: `Space` crosses the bridge as itself. Don't reintroduce an identity mapper for symmetry. The section key no longer needs a runtime guard either — `NoteSectionKey` is generated, so a variant added in Rust is a compile error. - **`null` space means "all spaces".** `SpacesStore.activeSpaceId()` is `null` when the user wants every space, and that is a choice, not a loading state — don't add an "All" row to the spaces data, notes would end up filed into it. A note always has a `spaceId`; creating one with no space available is refused on purpose. - **Deleting a space needs a refuge.** `notes.space_id` carries `ON DELETE CASCADE`, so `delete_space(id, targetSpaceId)` moves the notes _then_ deletes, in one transaction — there is no one-argument variant, which would have made data loss the default. It leaves `updated_at` alone (the canvas sorts on it, and touching it would float the whole absorbed space to the top). A space can't be its own refuge: `domain::space::validate_move_target` refuses it before any SQL runs. `targetSpaceId` is the first multi-word command argument, so it's the one that actually exercises Tauri's camelCase renaming. - **"À trier" = a note with a deadline.** The `untriaged` filter, the `⏳` badge and the "à trier bientôt" section hint all read the same field, `lifecycle`. It's set from the editor's date field, converted to the **end of the local day** (`endOfLocalDay`) — midnight would make a note dated today expired on the spot — and read back in local time too. Remove that field and all three affordances go permanently empty, which is exactly the state they were in before it existed. diff --git a/docs/architecture.md b/docs/architecture.md index 3366029..ff30aea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,8 +76,10 @@ The split is deliberately coarse. A module per function meant four files wrappin function each, four module headers, and a reader chasing `normalize` across the tree. Serde attributes sit on the domain types rather than on a separate DTO family. At this size a -second set of types and their mapping would cost more than it protects; the wire shape is -pinned by tests in `domain/note.rs`, `domain/view.rs` and `domain/space.rs` instead. +second set of types and their mapping would cost more than it protects. Each of these types +also derives `specta::Type`, which is what lets tauri-specta generate the front-end's +`bindings.ts` from them — a derive from a plain library crate, so `domain/` still knows +neither Tauri nor rusqlite and the direction grep stays clean. ## Front-end @@ -452,18 +454,41 @@ know `NotesStore` (the reverse dependency already exists, and closing the loop w injection cycle). `NotesPageComponent` chains the reload, which matters when the current query does not mention the deleted space and would otherwise show nothing new. -Keep this seam intact: no component calls `invoke()`, and `IpcService` is its only caller. +Keep this seam intact: no component or store calls a command, the repositories do. ## IPC boundary (Angular ↔ Rust) -All calls go through `IpcService` (`core/ipc/`) and every failure comes back as an `IpcError`. +**The boundary is generated, not written.** `src/app/core/ipc/bindings.ts` is produced by +[tauri-specta](https://docs.rs/tauri-specta) from the Rust signatures: one typed function per +command, plus a TypeScript type for every struct and enum that crosses the bridge. It is +committed — the front-end does not compile without it — and regenerated by -The command **and its arguments** are typed by the `IpcContract` table in -`core/ipc/ipc.service.ts`, mapping -each command name to its argument shape and return type. This matters more than it looks: -Tauri matches arguments **by name**, so a misspelled key used to compile fine and fail at -runtime as a serde rejection — an `IpcError` with no code, the most opaque failure the app -can produce. It is now a build error. +- `npm run tauri dev`, which rewrites it at every launch (`export_bindings()` in `lib.rs`, + behind `debug_assertions`), or +- `npm run bindings`, which runs the `export-bindings` binary alone when a Rust signature + changed and starting the whole app is not worth it. + +It deliberately is **not** a `#[test]`: on Windows the test executable lives in +`target/debug/deps/`, where the `WebView2Loader.dll` that `tauri-build` drops is absent, and +merely linking `Builder::export` there stops the binary from starting at all. + +This replaces the hand-written `IpcContract` table and the `tauri::generate_handler![…]` +list, which were two mirrors of the same thing kept in step by review. `collect_commands![…]` +in `lib.rs` is now the single list: it both registers the commands with Tauri and decides +what `bindings.ts` contains. Tauri matches arguments **by name** and renames them to +camelCase; nobody spells `targetSpaceId` by hand any more. + +Only `features/notes/data/` and `core/ipc/` import `bindings.ts`. Everything else keeps +importing the DTO aliases from `note.dto.ts`, so the generated file stays behind the same +boundary the hand-written types were behind. + +### Calling a command + +`commands.queryNotes(query)` returns a **discriminated result**, not a promise that rejects: +`{ status: 'ok', data }` or `{ status: 'error', error }`. Repositories pass it through +`unwrap()` (`core/ipc/ipc.error.ts`), which returns the data or throws an `IpcError`. Stores +and components therefore keep the `try`/`catch` they already had, and `ErrorNotifier` stays +the one place that branches on a cause. ### Error contract @@ -485,15 +510,16 @@ action — "this note no longer exists" beats "could not save the note", which w user retrying something that can never succeed. `fallback` is used when the cause adds nothing actionable (a generic SQLite failure) or when there is no code at all. -Its table is typed `Record`, so adding a variant to -`IpcErrorCode` fails the build until its key is decided. That is what makes the Rust ↔ front -mirror compiler-checked rather than review-checked. +`IpcErrorCode` is a plain alias of the **generated** `ErrorCode` union, so it is no longer a +mirror at all — adding a Rust variant makes it appear on the front at the next generation. +Two tables then fail to compile until the new case is handled: `CODE_KEYS`, typed +`Record`, and `IPC_ERROR_CODES` in `ipc.error.ts`. -`IpcError.code` is `null` when the rejection is not one of ours: Tauri itself rejects with a -plain string for an unknown command or an argument that fails to deserialise, and that case -must stay readable. It is also `null` for a code this build does not recognise — -`isIpcErrorPayload` validates the string against the known list rather than trusting it, so -the declared type cannot lie at runtime. +That second table is a runtime guard, and it still earns its place: `bindings.ts` _declares_ +the error branch as an `AppError`, but Tauri itself rejects with a plain **string** for an +unknown command or an argument that fails to deserialise, and that value lands in the same +branch. `IpcError.code` is `null` in exactly those cases, and the message falls back to the +raw cause so the failure stays readable. `ErrorCode` has no variant for a too-recent schema: that failure is only produced by the migration during Tauri's `setup()`, where it aborts startup. No command can return it, so @@ -501,30 +527,39 @@ giving it a code would advertise a case the front can never handle. ### Serialisation contract -**A DTO exists only where the wire shape differs from the domain shape.** `model/` is the -vocabulary the application reasons in — what stores, components and templates manipulate; -`data/` is the boundary — the shape that crosses the bridge, the repository that crosses it, -and the conversion between the two. Where the two shapes coincide, the model type travels as -it is: a space has no DTO, and `IpcContract` types `list_spaces` with `Space` directly. An -identity mapper is not symmetry, it is one more name for one type — and it teaches the reader -that "DTO" is decorative, which makes the notes case unreadable by contagion. - -Notes earn theirs four times over, and `features/notes/data/note.dto.ts` is where it lives. -Two traps it exists to handle: - -- **JSON has no date type.** Every `Date` becomes an ISO 8601 string on the wire. The mapper - parses it back and throws a `ContractError` on an unparseable value, rather than - letting an `Invalid Date` propagate and resurface as `NaN` in a relative-time label. -- **Serde's defaults do not match the TypeScript shape.** The Rust `Note` struct needs - `#[serde(rename_all = "camelCase")]` (otherwise the front receives `space_id` / - `created_at` where it expects `spaceId` / `createdAt`), and the - lifecycle enum needs `#[serde(tag = "kind", rename_all = "camelCase")]` (otherwise serde - emits `{"Expires":{…}}`, which the discriminated union does not recognise). +**The wire types are generated; the conversion is not.** `model/` is the vocabulary the +application reasons in — what stores, components and templates manipulate; `data/` is the +boundary — the shape that crosses the bridge, the repository that crosses it, and the +conversion between the two. What used to be hand-written wire interfaces are now aliases of +generated types (`export type NoteDto = DisplayNote`), kept in `note.dto.ts` so that callers +import the boundary vocabulary from the boundary, not from `bindings.ts`. + +Where the two shapes coincide, the model type travels as it is: a space still has no mapper. +An identity mapper is not symmetry, it is one more name for one type. + +What generation does **not** remove, and why `features/notes/data/note.dto.ts` is still the +biggest file in `data/`: + +- **JSON has no date type.** Rust types every timestamp as a `String`, so the bindings do too. + The mapper parses it into a `Date` and throws a `ContractError` on an unparseable value, + rather than letting an `Invalid Date` propagate and resurface as `NaN` in a relative-time + label. The reverse direction (`toIsoString`) guards the same way. +- **The front narrows what Rust leaves wide.** `language` is a free `String` in the domain; + the front restricts it to a `LanguageTag`. +- **A patch omits what it does not touch.** The Rust fields carry `#[specta(optional)]`, so + the generated `NotePatch` has optional keys and `toNotePatchDto` can copy field by field — + an explicit `undefined` would serialise to `null` and overwrite the stored value. + +The serde attributes are still load-bearing (`rename_all = "camelCase"` on the structs, +`tag = "kind"` on the data-carrying enums), but they no longer need to be mirrored by hand: +specta reads them and the generated types follow. The tests in `domain/note.rs` that pin the +JSON shape are now a second line of defence rather than the only one. An unknown `language` value degrades to `txt` instead of failing the load, and an unknown entry in `availableLanguages` is dropped from the rail: a newer backend may know a language -this front-end build does not. Contrast with an unknown section key, which does throw — a rail -missing one facet stays usable, a canvas with an unreadable section does not. +this front-end build does not — and since Rust types it as a plain string, the bindings cannot +rule it out. A section key needs no such guard any more: `NoteSectionKey` is generated, so a +variant added in Rust breaks the assignment at compile time instead of throwing at runtime. The known list is `domain/rules.rs` (`LANGUAGES`), mirrored by `core/language/language.model.ts` (`LanguageTag` + `LANGUAGE_LABELS`). Adding a language means editing both, plus a `.lang-*` @@ -555,27 +590,27 @@ who remember to touch the select. Three things keep it honest: so waiting for the blur would leave the badge on TXT — which reads as a failed detection. Plain typing stays deferred: that is what avoids one round trip per character. -Patches are serialised field by field, omitting absent keys — an explicit `undefined` would -serialise to `null` and overwrite the stored value instead of leaving it untouched. - ### Rules -- Argument names must match between the TS call site and the Rust signature — Tauri matches - by name, not position. Declare each command in `IpcContract` and the compiler enforces - it. ⚠️ Tauri v2 applies `rename_all = "camelCase"` to arguments, so a Rust parameter - `note_id` is `noteId` on the wire. No parameter is multi-word today, but the first one will - hit this. -- **Every** command must be registered in `tauri::generate_handler![...]` in - `src-tauri/src/lib.rs`, or the call fails at runtime even though the Rust compiles fine. +- A new command needs **one** registration: `collect_commands![…]` in `src-tauri/src/lib.rs`. + Annotate it `#[tauri::command]` **and** `#[specta::specta]`, then regenerate — an unannotated + function will not compile inside `collect_commands!`. +- Every type crossing the bridge must derive `specta::Type` alongside its serde derives. +- Specta refuses to export `usize`, `isize` and the 64-bit-and-wider integers, since JSON + cannot carry them without precision loss. Use a sized type the wire can hold — `NotesView.matched` + is a `u32` for exactly this reason. - Commands are **adapters only**: validate the input, lock the shared connection, delegate, translate the error. A command that grows is a sign a rule was written in the wrong place. +- `bindings.ts` is excluded from ESLint and Prettier: its shape belongs to the generator, and + reformatting it would make every regeneration a diff. ### The downward direction: events -`IpcContract` covers the front asking the back a question. The reverse — the back telling the +`bindings.ts` covers the front asking the back a question. The reverse — the back telling the front something happened — goes through **`AppEventsService`** (`core/ipc/app-events.service.ts`), -which wraps `listen` from `@tauri-apps/api/event`. `IpcService` keeps the upward direction and -stays the only caller of `invoke()`. +which wraps `listen` from `@tauri-apps/api/event`. tauri-specta can generate typed events too +(`collect_events![…]`); the desktop events are declared in `desktop.rs` rather than as command +payloads, so they are not part of the generated surface today. Today it carries the desktop integration, which lives in `src-tauri/src/desktop.rs` — global shortcuts and the system tray. That module is neither a command, a rule nor SQL, so it sits @@ -838,10 +873,10 @@ update. - **The user decides.** `check()` only produces an offer; `UpdateStore.accept()` is the only path that downloads. A silent update would restart the app mid-keystroke, and the editor only commits its drafts on blur. -- **`UpdaterService`** (`core/updates/`) is the seam, for the same reason `IpcService` is one: - no component or store imports `@tauri-apps/plugin-updater`, which needs a Tauri bridge that - jsdom does not have. These are plugin commands, not ours, so they cannot go through - `IpcContract`. The service also holds the plugin's `Update` object — a **native resource** +- **`UpdaterService`** (`core/updates/`) is the seam, for the same reason the repositories are + one: no component or store imports `@tauri-apps/plugin-updater`, which needs a Tauri bridge + that jsdom does not have. These are plugin commands, not ours, so they never appear in + `bindings.ts`. The service also holds the plugin's `Update` object — a **native resource** with a Rust-side id that must be closed if the offer is declined, hence `UpdaterService.discard()`. - **A failed check is silent; a failed install is not.** Offline, behind a proxy, or on a dev diff --git a/eslint.config.mjs b/eslint.config.mjs index 8ab21bd..8552355 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,9 @@ export default tseslint.config( 'coverage/**', // Maquette statique de référence, ni compilée ni importée. 'docs/**', + // Bindings générés par tauri-specta : leur forme est décidée par le + // générateur, pas par nos règles de style. + 'src/app/core/ipc/bindings.ts', ], }, { diff --git a/package.json b/package.json index 5725d68..6c50124 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lint:fix": "eslint . --fix && prettier --write .", "format": "prettier --write .", "tauri": "tauri", - "tauri:dev": "tauri dev" + "tauri:dev": "tauri dev", + "bindings": "cargo run --manifest-path src-tauri/Cargo.toml --bin export-bindings" }, "private": true, "dependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 668b771..3a68c36 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "adler2" version = "2.0.1" @@ -763,6 +769,8 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "specta", + "specta-typescript", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -771,6 +779,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", + "tauri-specta", "uuid", ] @@ -2688,6 +2697,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3629,6 +3644,57 @@ dependencies = [ "system-deps", ] +[[package]] +name = "specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" +dependencies = [ + "paste", + "rustc_version", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "specta-serde" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" +dependencies = [ + "specta", + "specta-macros", +] + +[[package]] +name = "specta-typescript" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-util" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" +dependencies = [ + "specta", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -3862,6 +3928,7 @@ dependencies = [ "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", "tauri-build", "tauri-macros", @@ -4118,6 +4185,37 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-serde", + "specta-typescript", + "specta-util", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.19", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tauri-utils" version = "2.9.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23df7de..6383c7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" description = "A Tauri App" authors = ["you"] edition = "2024" +# Le binaire `export-bindings` en fait un second : sans cette clé, le `cargo run` +# nu que lance `tauri dev` ne sait plus lequel démarrer. +default-run = "devbox" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -18,49 +21,29 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -# `tray-icon` : DevBox reste résidente dans la zone de notification, d'où on la -# rappelle et d'où on la quitte — la croix de la fenêtre ne fait plus que cacher. tauri = { version = "2", features = ["tray-icon"] } -# Sérialisation des structures échangées avec le front (voir domain/note.rs). +# Génération des types et des appels TypeScript à partir des signatures Rust +# (voir lib.rs). Versions figées par `=` : Specta v2 est en release candidate et +# ne garantit pas la compatibilité entre deux rc. +tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } +specta = "=2.0.0-rc.25" +specta-typescript = "=0.0.12" serde = { version = "1", features = ["derive"] } -# Exigé par `generate_context!` depuis que tauri.conf.json porte une section -# `plugins` : la macro embarque cette configuration sous forme de JSON. Sert -# aussi aux tests, qui verrouillent la forme traversant le pont (domain/note.rs). serde_json = "1.0.151" -# Stockage : SQLite embarqué (voir storage/mod.rs). La feature `bundled` compile -# SQLite depuis les sources et le lie en statique — aucune DLL à distribuer. rusqlite = { version = "0.40.1", features = ["bundled"] } -# Identifiants des notes et des espaces : le front n'en fabrique jamais. uuid = { version = "1.24.0", features = ["v4"] } -# Horodatages ISO 8601 / RFC 3339 UTC, le format attendu par le pont Tauri. chrono = "0.4.45" -# Redémarrage de l'application après l'installation d'une mise à jour. tauri-plugin-process = "2" -# Ouverture du dépôt dans le navigateur système. Le CSP est verrouillé sur -# `'self'` : un lien ordinaire ne mène nulle part depuis la WebView. tauri-plugin-opener = "2" -# Préférences d'interface (langue, plein écran) dans un vrai fichier sur disque -# plutôt que dans le `localStorage` de la WebView, qu'un vidage de cache efface -# et que Rust ne sait pas relire. Voir core/preferences/preferences.service.ts. tauri-plugin-store = "2" -# Presse-papier : sortir un snippet de DevBox, et y entrer celui d'une autre -# application. La WebView ne donne pas accès au presse-papier système sous CSP. tauri-plugin-clipboard-manager = "2" - -# L'updater n'existe pas sur mobile — les magasins d'applications s'en chargent — -# et le déclarer inconditionnellement casserait une compilation Android/iOS. -# Les raccourcis globaux non plus : un système mobile ne laisse pas une -# application écouter le clavier hors de sa fenêtre. [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" -# Le Rust n'avait ni linter ni garde-fou : `cargo clippy -- -D warnings` échouait -# sur rien, donc rien n'empêchait une régression d'entrer. [lints.rust] unsafe_code = "forbid" [lints.clippy] -# `priority = -1` laisse la possibilité de rétrograder une lint précise ensuite. all = { level = "deny", priority = -1 } diff --git a/src-tauri/src/bin/export-bindings.rs b/src-tauri/src/bin/export-bindings.rs new file mode 100644 index 0000000..b1130a6 --- /dev/null +++ b/src-tauri/src/bin/export-bindings.rs @@ -0,0 +1,10 @@ +//! Régénère `src/app/core/ipc/bindings.ts` sans ouvrir de fenêtre. +//! +//! `npm run tauri dev` le fait déjà au lancement, mais travailler côté front +//! sans démarrer l'application reste courant — et l'attente d'un build Tauri +//! complet pour une signature modifiée ne se justifie pas. + +fn main() { + devbox_lib::export_bindings().expect("échec de la génération des bindings TypeScript"); + println!("bindings.ts régénéré"); +} diff --git a/src-tauri/src/commands/error.rs b/src-tauri/src/commands/error.rs index 6a5a369..3fa1a84 100644 --- a/src-tauri/src/commands/error.rs +++ b/src-tauri/src/commands/error.rs @@ -7,16 +7,19 @@ use std::collections::BTreeMap; use serde::Serialize; +use specta::Type; use crate::domain::rules::ValidationError; use crate::storage::StorageError; -/// ⚠️ Ajouter une variante impose d'ajouter la sienne dans `IpcErrorCode` -/// (`src/app/core/ipc/ipc-error.ts`) **et** sa clé dans les deux locales. +/// Ajouter une variante la fait apparaître dans le `bindings.ts` généré, ce qui +/// casse la compilation du front tant que `CODE_KEYS` +/// (`core/errors/error-notifier.service.ts`) et les deux locales n'ont pas leur +/// clé — le miroir n'est plus tenu à la main. /// /// Pas de variante « schéma trop récent » : cette panne avorte le lancement /// pendant la migration, aucune commande ne peut la renvoyer. -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Copy, Serialize, Type)] #[serde(rename_all = "camelCase")] pub enum ErrorCode { NoteNotFound, @@ -30,7 +33,7 @@ pub enum ErrorCode { Storage, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct AppError { pub code: ErrorCode, diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index c02d127..fbb99fc 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -17,6 +17,7 @@ use crate::storage::{self, Db}; /// Notes filtrées **et** regroupées, prêtes à afficher. Aucune commande ne rend /// la liste brute : elle inviterait à refiltrer côté front. #[tauri::command] +#[specta::specta] pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result { let connection = lock(&db)?; let (notes, facets) = storage::notes::fetch(&connection, &query)?; @@ -25,6 +26,7 @@ pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result) -> Result { let draft = detect::with_detected_language(draft); @@ -38,6 +40,7 @@ pub fn create_note(draft: NoteDraft, db: State<'_, Db>) -> Result) -> Result<(), AppError> { let connection = lock(&db)?; diff --git a/src-tauri/src/commands/spaces.rs b/src-tauri/src/commands/spaces.rs index 3391740..55f5a41 100644 --- a/src-tauri/src/commands/spaces.rs +++ b/src-tauri/src/commands/spaces.rs @@ -17,6 +17,7 @@ use crate::domain::space::{self, Space, SpaceDraft}; use crate::storage::{self, Db}; #[tauri::command] +#[specta::specta] pub fn list_spaces(db: State<'_, Db>) -> Result, AppError> { let connection = lock(&db)?; @@ -25,6 +26,7 @@ pub fn list_spaces(db: State<'_, Db>) -> Result, AppError> { /// Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. #[tauri::command] +#[specta::specta] pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result { // Nom déjà détouré et non vide : le stockage n'a plus qu'à trancher // l'unicité, la seule chose que lui seul peut voir. @@ -37,6 +39,7 @@ pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result) -> Result { let name = draft.validated_name()?; @@ -47,9 +50,10 @@ pub fn rename_space(id: String, draft: SpaceDraft, db: State<'_, Db>) -> Result< /// Supprime un espace après avoir transféré ses notes vers `target_space_id`. /// -/// ⚠️ Tauri v2 renomme les arguments en camelCase : le front envoie -/// `targetSpaceId`, pas `target_space_id` (cf. `IpcContract`). +/// Tauri v2 renomme les arguments en camelCase ; c'est `bindings.ts` qui porte +/// désormais le `targetSpaceId` correspondant, sans qu'on ait à l'orthographier. #[tauri::command] +#[specta::specta] pub fn delete_space( id: String, target_space_id: String, diff --git a/src-tauri/src/commands/tray.rs b/src-tauri/src/commands/tray.rs index bd1f419..e841d6c 100644 --- a/src-tauri/src/commands/tray.rs +++ b/src-tauri/src/commands/tray.rs @@ -5,11 +5,12 @@ //! une seconde à tenir en phase. Le natif ne fait que les afficher. use serde::Deserialize; +use specta::Type; use tauri::AppHandle; use crate::desktop; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct TrayLabels { pub open: String, @@ -23,6 +24,7 @@ pub struct TrayLabels { /// ajouterait une branche que rien n'afficherait jamais. L'échec est journalisé /// côté natif, comme pour un raccourci global indisponible. #[tauri::command] +#[specta::specta] pub fn sync_tray(labels: TrayLabels, app: AppHandle) { desktop::sync_tray(&app, &labels); } diff --git a/src-tauri/src/domain/note.rs b/src-tauri/src/domain/note.rs index 00795ba..824e2b6 100644 --- a/src-tauri/src/domain/note.rs +++ b/src-tauri/src/domain/note.rs @@ -15,10 +15,11 @@ use chrono::{DateTime, FixedOffset, Utc}; use serde::{Deserialize, Serialize}; +use specta::Type; use super::rules::{self, ValidationError}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Note { pub id: String, @@ -41,7 +42,7 @@ pub struct Note { pub lifecycle: NoteLifecycle, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum NoteLifecycle { /// Note permanente. @@ -51,7 +52,7 @@ pub enum NoteLifecycle { } /// Création : ni identifiant ni horodatages — c'est la persistance qui les attribue. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NoteDraft { pub space_id: String, @@ -65,17 +66,30 @@ pub struct NoteDraft { } /// Modification partielle : un champ à `None` reste **inchangé** en base. -#[derive(Debug, Clone, Default, Deserialize)] +/// +/// `#[specta(optional)]` génère `title?: string | null` plutôt que +/// `title: string | null` : le front **omet** les clés qu'il ne touche pas, et +/// un type qui les exigerait toutes l'obligerait à envoyer des `null`, c'est-à-dire +/// à écraser ce qu'il voulait laisser intact. +#[derive(Debug, Clone, Default, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotePatch { /// Renseigné uniquement lors d'un déplacement de note vers un autre espace. + #[specta(optional)] pub space_id: Option, + #[specta(optional)] pub title: Option, + #[specta(optional)] pub language: Option, + #[specta(optional)] pub content: Option, + #[specta(optional)] pub source: Option, + #[specta(optional)] pub tags: Option>, + #[specta(optional)] pub pinned: Option, + #[specta(optional)] pub lifecycle: Option, } @@ -103,7 +117,7 @@ const EXPIRING_SOON_DAYS: i64 = 3; const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; /// Contenu du pied d'une carte — la **décision**, pas le rendu. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum NoteFooter { /// Note épinglée portant un contexte : elle est là pour durer, savoir d'où @@ -117,7 +131,7 @@ pub enum NoteFooter { /// Note augmentée de ce que l'affichage doit savoir. `flatten` aplatit la note /// dans l'objet JSON : le front n'a qu'un seul type de note. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct DisplayNote { #[serde(flatten)] diff --git a/src-tauri/src/domain/space.rs b/src-tauri/src/domain/space.rs index be5ff07..02a69d3 100644 --- a/src-tauri/src/domain/space.rs +++ b/src-tauri/src/domain/space.rs @@ -5,10 +5,11 @@ //! et en créer un ferait ranger des notes dedans. use serde::{Deserialize, Serialize}; +use specta::Type; use super::rules::ValidationError; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Space { pub id: String, @@ -18,7 +19,7 @@ pub struct Space { } /// Pas d'identifiant : il est attribué par la persistance. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct SpaceDraft { pub name: String, diff --git a/src-tauri/src/domain/view.rs b/src-tauri/src/domain/view.rs index 769c0bc..a197d40 100644 --- a/src-tauri/src/domain/view.rs +++ b/src-tauri/src/domain/view.rs @@ -12,6 +12,7 @@ use chrono::DateTime; use serde::{Deserialize, Serialize}; +use specta::Type; use super::note::{DisplayNote, Note}; use super::rules::{self, ValidationError}; @@ -19,7 +20,7 @@ use super::sections; /// Tout y est explicite : la requête ne lit ni horloge ni fuseau, ce qui la /// rend reproductible en test. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotesQuery { /// `None` = « tous les espaces » — un choix, pas une absence de choix : il @@ -42,7 +43,7 @@ pub struct NotesQuery { /// Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date /// d'expiration, c'est-à-dire celles dont on n'a pas encore décidé du sort. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum NoteFilter { All, @@ -59,7 +60,7 @@ pub struct Facets { } /// Ce que le canevas affiche, tel quel. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotesView { pub sections: Vec, @@ -71,11 +72,13 @@ pub struct NotesView { /// Une recherche ou une facette est active. Le front distingue ainsi /// « aucun résultat » d'« espace vide ». pub is_filtering: bool, - /// Notes retenues, toutes sections confondues. - pub matched: usize, + /// Notes retenues, toutes sections confondues. `u32` et non `usize` : Specta + /// refuse d'exporter les types de la taille d'un `BigInt`, que JSON ne sait + /// pas rendre sans perte de précision. + pub matched: u32, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct NoteSection { pub key: NoteSectionKey, @@ -88,7 +91,7 @@ pub struct NoteSection { /// Sert de **clé de traduction** côté front (`sections.`) : aucun libellé /// lisible ne traverse le pont. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] #[serde(rename_all = "camelCase")] pub enum NoteSectionKey { Pinned, @@ -122,7 +125,9 @@ pub fn build( let is_filtering = !needle.is_empty() || !rules::normalize_tags(&request.tags).is_empty() || !request.languages.is_empty(); - let matched = notes.len(); + // Le nombre de notes d'un espace ne déborde pas d'un `u32`, et une saturation + // reste préférable à une panne : ce compteur ne sert qu'à un libellé. + let matched = u32::try_from(notes.len()).unwrap_or(u32::MAX); let offset = sections::offset_from_minutes(request.tz_offset_minutes); let now = DateTime::parse_from_rfc3339(&request.now) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e9f2866..aeb5d21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,14 +7,62 @@ mod storage; use std::sync::Mutex; use tauri::Manager; +use tauri_specta::{Builder, collect_commands}; use commands::notes::{create_note, delete_note, query_notes, update_note}; use commands::spaces::{create_space, delete_space, list_spaces, rename_space}; use commands::tray::sync_tray; +/// Destination du `bindings.ts` généré. Il est versionné : le front ne compile +/// pas sans lui. +/// +/// Résolu depuis le manifeste et non depuis le répertoire courant : ni `tauri dev` +/// ni `cargo run --manifest-path` ne garantissent lequel c'est, et un chemin +/// relatif écrivait le fichier à côté du dépôt sans rien signaler. +const BINDINGS_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../src/app/core/ipc/bindings.ts" +); + +/// Réécrit `bindings.ts` sans lancer l'application — c'est ce qu'appelle le +/// binaire `export-bindings`, et donc `npm run bindings`. +/// +/// Volontairement pas un `#[cfg(test)]` : sous Windows l'exécutable de test vit +/// dans `target/debug/deps/`, où le `WebView2Loader.dll` posé par `tauri-build` +/// est absent, et le seul fait de lier `export` y empêche le binaire de démarrer. +pub fn export_bindings() -> Result<(), specta_typescript::Error> { + ipc_builder().export(specta_typescript::Typescript::default(), BINDINGS_PATH) +} + +/// Source **unique** des signatures : ce qui est collecté ici est à la fois +/// enregistré auprès de Tauri et écrit dans le `bindings.ts` du front. Une +/// commande absente de cette liste n'existe donc plus côté TypeScript non plus, +/// là où l'ancien `generate_handler!` laissait les deux dériver l'un de l'autre. +fn ipc_builder() -> Builder { + Builder::::new().commands(collect_commands![ + query_notes, + create_note, + update_note, + delete_note, + list_spaces, + create_space, + rename_space, + delete_space, + sync_tray, + ]) +} + /// Point d'entrée de l'application, natif sur mobile via `mobile_entry_point`. #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let builder = ipc_builder(); + + // Régénéré à chaque lancement de `npm run tauri dev`, pour qu'une signature + // Rust modifiée casse le front tout de suite. Pas en release : le `src/` du + // front n'existe pas à côté d'un binaire installé. + #[cfg(debug_assertions)] + export_bindings().expect("échec de la génération des bindings TypeScript"); + tauri::Builder::default() .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) @@ -59,18 +107,7 @@ pub fn run() { let _ = _window.hide(); } }) - // Sans enregistrement ici, `invoke()` échoue sur « command not found ». - .invoke_handler(tauri::generate_handler![ - query_notes, - create_note, - update_note, - delete_note, - list_spaces, - create_space, - rename_space, - delete_space, - sync_tray, - ]) + .invoke_handler(builder.invoke_handler()) .run(tauri::generate_context!()) .expect("erreur au lancement de l'application Tauri"); } diff --git a/src/app/core/app-info/app-info.service.ts b/src/app/core/app-info/app-info.service.ts index 0323561..123a4d7 100644 --- a/src/app/core/app-info/app-info.service.ts +++ b/src/app/core/app-info/app-info.service.ts @@ -17,8 +17,8 @@ export const APP_NAME = 'DevBox'; /** * Seam vers les API Tauri décrivant l'application. Même raison d'être que - * `UpdaterService` : ce sont des commandes du cœur et d'un plugin, absentes - * d'`IpcContract`, et un composant qui les importerait deviendrait intestable — + * `UpdaterService` : ce sont des commandes du cœur et d'un plugin, absentes de + * `bindings.ts`, et un composant qui les importerait deviendrait intestable — * jsdom n'a pas de pont Tauri. * * Pas de description ici : c'est du texte visible, donc une clé de traduction. diff --git a/src/app/core/ipc/app-events.service.ts b/src/app/core/ipc/app-events.service.ts index 99b94ef..a01e2f2 100644 --- a/src/app/core/ipc/app-events.service.ts +++ b/src/app/core/ipc/app-events.service.ts @@ -19,8 +19,8 @@ export const EVENT_SUBSCRIBER = new InjectionToken('EVENT_SUBSC }); /** - * Sens **descendant** du pont : le natif prévient, le front réagit. `IpcService` - * garde le sens montant et reste l'unique appelant d'`invoke()`. + * Sens **descendant** du pont : le natif prévient, le front réagit. Le sens + * montant passe par les commandes générées dans `bindings.ts`. * * Hors Tauri (jsdom), `listen` échoue : l'abonnement est alors inerte plutôt que * fatal, comme pour les préférences et le presse-papier. diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts new file mode 100644 index 0000000..e6b66b9 --- /dev/null +++ b/src/app/core/ipc/bindings.ts @@ -0,0 +1,250 @@ +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core"; + +/** Commands */ +export const commands = { + /** + * Notes filtrées **et** regroupées, prêtes à afficher. Aucune commande ne rend + * la liste brute : elle inviterait à refiltrer côté front. + */ + queryNotes: (query: NotesQuery) => typedError(__TAURI_INVOKE("query_notes", { query })), + createNote: (draft: NoteDraft) => typedError(__TAURI_INVOKE("create_note", { draft })), + updateNote: (id: string, patch: NotePatch) => typedError(__TAURI_INVOKE("update_note", { id, patch })), + deleteNote: (id: string) => typedError(__TAURI_INVOKE("delete_note", { id })), + listSpaces: () => typedError(__TAURI_INVOKE("list_spaces")), + /** Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. */ + createSpace: (draft: SpaceDraft) => typedError(__TAURI_INVOKE("create_space", { draft })), + /** Même brouillon qu'à la création, donc même validation. */ + renameSpace: (id: string, draft: SpaceDraft) => typedError(__TAURI_INVOKE("rename_space", { id, draft })), + /** + * Supprime un espace après avoir transféré ses notes vers `target_space_id`. + * + * Tauri v2 renomme les arguments en camelCase ; c'est `bindings.ts` qui porte + * désormais le `targetSpaceId` correspondant, sans qu'on ait à l'orthographier. + */ + deleteSpace: (id: string, targetSpaceId: string) => typedError(__TAURI_INVOKE("delete_space", { id, targetSpaceId })), + /** + * Ne renvoie **pas** de `Result` : une barre système absente n'est pas une + * panne que le front puisse traiter, et lui inventer un code d'erreur + * ajouterait une branche que rien n'afficherait jamais. L'échec est journalisé + * côté natif, comme pour un raccourci global indisponible. + */ + syncTray: (labels: TrayLabels) => __TAURI_INVOKE("sync_tray", { labels }), +}; + +/* Types */ +export type AppError = { + code: ErrorCode, + /** Valeurs à interpoler dans le message traduit, ex. `{ "name": "Perso" }`. */ + params: { [key in string]: string }, + /** + * Message technique, affiché en second plan de la bannière. Pas traduit, + * mais lisible. + */ + detail: string, +}; + +/** + * Note augmentée de ce que l'affichage doit savoir. `flatten` aplatit la note + * dans l'objet JSON : le front n'a qu'un seul type de note. + */ +export type DisplayNote = { + footer: NoteFooter, + expiringSoon: boolean, +} & Note; + +/** + * Ajouter une variante la fait apparaître dans le `bindings.ts` généré, ce qui + * casse la compilation du front tant que `CODE_KEYS` + * (`core/errors/error-notifier.service.ts`) et les deux locales n'ont pas leur + * clé — le miroir n'est plus tenu à la main. + * + * Pas de variante « schéma trop récent » : cette panne avorte le lancement + * pendant la migration, aucune commande ne peut la renvoyer. + */ +export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" | +/** Donnée reçue non conforme. Le paramètre `field` nomme le champ en cause. */ +"invalidInput" | +/** Mutex empoisonné : une commande a paniqué en tenant la connexion. */ +"storageUnavailable" | +/** Panne de lecture ou d'écriture SQLite. */ +"storage"; + +export type Note = { + id: string, + /** + * Espace de rangement. C'est la requête qui filtre dessus ; le stockage + * refuse de créer une note dans un espace inconnu. + */ + spaceId: string, + /** + * Peut être vide : une note fraîchement créée n'a pas encore de titre, + * l'interface affiche un libellé traduit à la place. + */ + title: string, + /** "json" | "js" | "py" | "sql" | "yml" | "txt". */ + language: string, + content: string, + /** Chemin de contexte libre, ex. "API Gateway / Auth". Peut être vide. */ + source: string, + tags: string[], + pinned: boolean, + /** ISO 8601, ex. "2026-07-25T09:12:00.000Z". */ + createdAt: string, + updatedAt: string, + lifecycle: NoteLifecycle, +}; + +/** Création : ni identifiant ni horodatages — c'est la persistance qui les attribue. */ +export type NoteDraft = { + spaceId: string, + title: string, + language: string, + content: string, + source: string, + tags: string[], + pinned: boolean, + lifecycle: NoteLifecycle, +}; + +/** + * Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date + * d'expiration, c'est-à-dire celles dont on n'a pas encore décidé du sort. + */ +export type NoteFilter = "all" | "pinned" | "untriaged"; + +/** Contenu du pied d'une carte — la **décision**, pas le rendu. */ +export type NoteFooter = +/** + * Note épinglée portant un contexte : elle est là pour durer, savoir d'où + * elle vient est plus utile que son âge. + */ +{ kind: "source"; value: string } | +/** Échéance d'une note éphémère. */ +{ kind: "expiry"; at: string } | +/** Âge de la dernière modification — le cas ordinaire. */ +{ kind: "age"; at: string }; + +export type NoteLifecycle = +/** Note permanente. */ +{ kind: "permanent" } | +/** Note éphémère : elle est « à trier » jusqu'à cette date. */ +{ kind: "expires"; at: string }; + +/** + * Modification partielle : un champ à `None` reste **inchangé** en base. + * + * `#[specta(optional)]` génère `title?: string | null` plutôt que + * `title: string | null` : le front **omet** les clés qu'il ne touche pas, et + * un type qui les exigerait toutes l'obligerait à envoyer des `null`, c'est-à-dire + * à écraser ce qu'il voulait laisser intact. + */ +export type NotePatch = { + /** Renseigné uniquement lors d'un déplacement de note vers un autre espace. */ + spaceId?: string | null, + title?: string | null, + language?: string | null, + content?: string | null, + source?: string | null, + tags?: string[] | null, + pinned?: boolean | null, + lifecycle?: NoteLifecycle | null, +}; + +export type NoteSection = { + key: NoteSectionKey, + /** Au moins une note arrive à échéance, au sens du seuil unique de `note`. */ + hasExpiringNotes: boolean, + notes: DisplayNote[], + /** Affiche la carte fantôme « coller ou créer » en fin de section. */ + showCreateGhost: boolean, +}; + +/** + * Sert de **clé de traduction** côté front (`sections.`) : aucun libellé + * lisible ne traverse le pont. + */ +export type NoteSectionKey = "pinned" | "today" | "week" | "older" | "results"; + +/** + * Tout y est explicite : la requête ne lit ni horloge ni fuseau, ce qui la + * rend reproductible en test. + */ +export type NotesQuery = { + /** + * `None` = « tous les espaces » — un choix, pas une absence de choix : il + * n'existe aucun espace « Tous » côté données. + */ + spaceId: string | null, + /** Cherché dans le titre, les tags et le contenu. Vide = pas de recherche. */ + search: string, + filter: NoteFilter, + /** Tags du rail. Une note passe si elle en porte **au moins un**. */ + tags: string[], + /** Langages du rail, même sémantique d'union. Vide = tous. */ + languages: string[], + /** Instant de référence ISO 8601 UTC, fourni par `ClockService`. */ + now: string, + /** + * ⚠️ `Date#getTimezoneOffset()`, dont la valeur est l'**opposé** du décalage + * (UTC+2 donne −120). Nécessaire parce que les sections raisonnent en jours + * locaux : à 23 h à Paris, `now` en UTC est déjà demain. + */ + tzOffsetMinutes: number, +}; + +/** Ce que le canevas affiche, tel quel. */ +export type NotesView = { + sections: NoteSection[], + /** + * Portés à l'**espace**, pas au filtre courant : n'afficher que les tags des + * notes déjà filtrées rendrait le rail inutilisable dès la 1re sélection. + */ + availableTags: string[], + /** Portés à l'espace, même raison. */ + availableLanguages: string[], + /** + * Une recherche ou une facette est active. Le front distingue ainsi + * « aucun résultat » d'« espace vide ». + */ + isFiltering: boolean, + /** + * Notes retenues, toutes sections confondues. `u32` et non `usize` : Specta + * refuse d'exporter les types de la taille d'un `BigInt`, que JSON ne sait + * pas rendre sans perte de précision. + */ + matched: number, +}; + +export type Space = { + id: string, + /** + * L'unicité, insensible à la casse, est tranchée par la persistance : un + * doublon ressort en `ErrorCode::DuplicateSpaceName`. + */ + name: string, +}; + +/** Pas d'identifiant : il est attribué par la persistance. */ +export type SpaceDraft = { + name: string, +}; + +export type TrayLabels = { + open: string, + newNote: string, + capture: string, + quit: string, +}; + +/* Tauri Specta runtime */ +async function typedError(result: Promise): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> { + try { + return { status: "ok", data: await result }; + } catch (e) { + if (e instanceof Error) throw e; + return { status: "error", error: e as any }; + } +} + diff --git a/src/app/core/ipc/ipc.service.spec.ts b/src/app/core/ipc/ipc.error.spec.ts similarity index 70% rename from src/app/core/ipc/ipc.service.spec.ts rename to src/app/core/ipc/ipc.error.spec.ts index f70764a..dc87590 100644 --- a/src/app/core/ipc/ipc.service.spec.ts +++ b/src/app/core/ipc/ipc.error.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { IpcError } from './ipc.error'; +import { IpcError, unwrap } from './ipc.error'; describe('IpcError', () => { it('keeps the failing command and the raw cause', () => { @@ -73,3 +73,33 @@ describe('IpcError', () => { }); }); }); + +describe('unwrap', () => { + it('returns the data of a successful result', () => { + expect(unwrap('list_spaces', { status: 'ok', data: [{ id: 's-1', name: 'Perso' }] })).toEqual([ + { id: 's-1', name: 'Perso' }, + ]); + }); + + it('throws an IpcError carrying the backend code, so callers keep using try/catch', () => { + // The generated bindings return a discriminated result; turning it back into + // an exception is what keeps ErrorNotifier the single place that branches. + const failing = () => + unwrap('create_space', { + status: 'error', + error: { code: 'duplicateSpaceName', params: { name: 'Perso' }, detail: 'déjà pris' }, + }); + + expect(failing).toThrow(IpcError); + try { + failing(); + } catch (error) { + expect((error as IpcError).code).toBe('duplicateSpaceName'); + expect((error as IpcError).params['name']).toBe('Perso'); + } + }); + + it('keeps a null data payload, which is what a void command returns', () => { + expect(unwrap('delete_note', { status: 'ok', data: null })).toBeNull(); + }); +}); diff --git a/src/app/core/ipc/ipc.error.ts b/src/app/core/ipc/ipc.error.ts index ea786c2..bfa8838 100644 --- a/src/app/core/ipc/ipc.error.ts +++ b/src/app/core/ipc/ipc.error.ts @@ -1,52 +1,45 @@ -import { IpcCommand } from './ipc.service'; +import type { AppError, ErrorCode } from './bindings'; /** - * Causes d'échec que le backend sait nommer, en miroir de `ErrorCode` - * (`src-tauri/src/commands/error.rs`). Ajouter une variante d'un côté impose de - * l'ajouter de l'autre — **et** de la déclarer dans `IPC_ERROR_CODES`, sinon - * elle arrivera en `null`. + * Causes d'échec que le backend sait nommer. Simple alias de l'union **générée** + * depuis `ErrorCode` (`src-tauri/src/commands/error.rs`) : ce n'est plus un + * miroir tenu à la main, une variante ajoutée en Rust apparaît ici dès la + * régénération et casse la compilation partout où elle n'est pas traitée. * * Ce sont des **codes**, jamais du texte : c'est ce qui permet de réagir à une * cause précise et d'afficher un message traduit, là où une chaîne rédigée en * Rust imposerait sa langue à toute l'interface. - * - * `schemaTooRecent` n'y figure volontairement pas : il n'est produit que par la - * migration, pendant le `setup()` de Tauri, où l'échec avorte le lancement. Il - * ne peut pas traverser le pont, et le déclarer ici laisserait croire le contraire. */ -export type IpcErrorCode = - 'noteNotFound' | 'spaceNotFound' | 'duplicateSpaceName' | 'invalidInput' | 'storageUnavailable' | 'storage'; - -const IPC_ERROR_CODES: readonly IpcErrorCode[] = [ - 'noteNotFound', - 'spaceNotFound', - 'duplicateSpaceName', - 'invalidInput', - 'storageUnavailable', - 'storage', -]; +export type IpcErrorCode = ErrorCode; -function isIpcErrorCode(value: string): value is IpcErrorCode { - return (IPC_ERROR_CODES as readonly string[]).includes(value); -} +/** + * Forme d'un `Result` Rust vue du TypeScript, telle que `bindings.ts` la rend. + * Redéclarée plutôt qu'importée : le générateur l'écrit en ligne dans chaque + * signature, sans jamais la nommer. + */ +export type IpcResult = { status: 'ok'; data: T } | { status: 'error'; error: AppError }; -/** Forme sérialisée d'un `AppError` Rust. */ -interface IpcErrorPayload { - readonly code: IpcErrorCode; - readonly params: Record; - readonly detail: string; -} +/** + * Exhaustif par construction : ajouter une variante à `ErrorCode` en Rust rend + * cet objet incomplet, donc la compilation échoue ici. Nécessaire malgré le + * typage parce que `bindings.ts` **annonce** un `AppError` là où Tauri peut + * avoir rejeté avec autre chose (voir [`IpcError`]). + */ +const IPC_ERROR_CODES: Record = { + noteNotFound: true, + spaceNotFound: true, + duplicateSpaceName: true, + invalidInput: true, + storageUnavailable: true, + storage: true, +}; -function isIpcErrorPayload(cause: unknown): cause is IpcErrorPayload { +function isAppError(cause: unknown): cause is AppError { if (typeof cause !== 'object' || cause === null) return false; - const candidate = cause as Partial; - // `code` est confronté à la liste, pas seulement à son type : un back plus - // récent enverrait sinon une variante inconnue que `IpcErrorCode` prétendrait - // couvrir, et les appelants qui discriminent dessus tomberaient dans un cas - // qu'ils croient impossible. + const candidate = cause as Partial; return ( typeof candidate.code === 'string' && - isIpcErrorCode(candidate.code) && + candidate.code in IPC_ERROR_CODES && typeof candidate.detail === 'string' ); } @@ -58,12 +51,13 @@ function describeCause(cause: unknown): string { } /** - * Échec d'un appel `invoke()`. + * Échec d'une commande. * * `code` vaut `null` quand le rejet ne vient pas de nos commandes : Tauri * rejette lui-même avec une **chaîne** si la commande est inconnue ou si un - * argument ne se désérialise pas. Ce cas doit rester lisible, d'où le repli sur - * `describeCause`. + * argument ne se désérialise pas, et `bindings.ts` la range dans la branche + * `error` en la typant `AppError` qu'elle n'est pas. Ce cas doit rester lisible, + * d'où le repli sur `describeCause`. */ export class IpcError extends Error { readonly code: IpcErrorCode | null; @@ -71,13 +65,28 @@ export class IpcError extends Error { readonly params: Record; constructor( - readonly command: IpcCommand, + readonly command: string, override readonly cause: unknown, ) { - const structured = isIpcErrorPayload(cause) ? cause : null; + const structured = isAppError(cause) ? cause : null; super(`La commande Tauri « ${command} » a échoué : ${structured?.detail ?? describeCause(cause)}`); this.name = 'IpcError'; this.code = structured?.code ?? null; this.params = structured?.params ?? {}; } } + +/** + * Convertit le `Result` discriminé des bindings en valeur ou en exception. + * + * Les dépôts lèvent plutôt que de propager le `status` : les stores et les + * composants réagissent déjà à un `catch`, et faire remonter le discriminant + * jusqu'aux appelants leur ferait porter une branche que `ErrorNotifier` traite + * en un seul endroit. + */ +export function unwrap(command: string, result: IpcResult): T { + if (result.status === 'error') { + throw new IpcError(command, result.error); + } + return result.data; +} diff --git a/src/app/core/ipc/ipc.service.ts b/src/app/core/ipc/ipc.service.ts deleted file mode 100644 index dc4b396..0000000 --- a/src/app/core/ipc/ipc.service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Injectable } from '@angular/core'; -import { InvokeArgs, invoke } from '@tauri-apps/api/core'; -import type { - NoteDraftDto, - NoteDto, - NotePatchDto, - NotesQueryDto, - NotesViewDto, -} from '@features/notes/data/note.dto'; -import type { Space, SpaceDraft } from '@features/notes/model/space.model'; -import type { TrayLabels } from '@core/tray/tray.service'; -import { IpcError } from './ipc.error'; - -/** - * Signature de chaque commande Tauri : nom, arguments, valeur de retour. - * - * Tauri apparie les arguments **par nom**. Une clé mal orthographiée compilerait - * sans broncher et n'échouerait qu'à l'exécution, sur un rejet serde — donc un - * `IpcError` sans code, le cas le plus opaque à diagnostiquer. Cette table en - * fait une erreur de build. - * - * ⚠️ Tauri v2 applique `rename_all = "camelCase"` aux arguments : un paramètre - * Rust `target_space_id` s'écrit `targetSpaceId` ici. Les imports sont - * `import type` pour ne créer aucun cycle avec `features/notes/data`. - * - * Un espace circule sous son type du modèle : sa forme sur le fil est celle du - * domaine, donc il n'a pas de DTO — contrairement à une note, dont les dates et - * les unions demandent une conversion (`note.dto.ts`). - */ -export interface IpcContract { - readonly query_notes: { args: { query: NotesQueryDto }; result: NotesViewDto }; - readonly create_note: { args: { draft: NoteDraftDto }; result: NoteDto }; - readonly update_note: { args: { id: string; patch: NotePatchDto }; result: NoteDto }; - readonly delete_note: { args: { id: string }; result: void }; - /** `db: State` est injecté par Tauri, pas fourni par le front. */ - readonly list_spaces: { args: undefined; result: readonly Space[] }; - readonly create_space: { args: { draft: SpaceDraft }; result: Space }; - readonly rename_space: { args: { id: string; draft: SpaceDraft }; result: Space }; - readonly delete_space: { args: { id: string; targetSpaceId: string }; result: void }; - /** - * Seule commande sans `Result` côté Rust : une barre système absente n'est pas - * une panne que le front puisse traiter (voir `commands/tray.rs`). - */ - readonly sync_tray: { args: { labels: TrayLabels }; result: void }; -} - -export type IpcCommand = keyof IpcContract; -export type IpcResult = IpcContract[C]['result']; - -/** - * Argument optionnel exactement pour les commandes qui n'en prennent pas, au - * lieu d'un `args?` uniformément facultatif qui laisserait passer un appel - * dépourvu de sa charge utile. - */ -export type IpcInvocation = IpcContract[C]['args'] extends undefined - ? [] - : [IpcContract[C]['args']]; - -/** - * Unique point de passage vers le backend Rust : aucun composant ni store - * n'appelle `invoke()` directement. Centralise la normalisation des erreurs et - * rend les dépôts testables en doublant ce seul service. - */ -@Injectable({ providedIn: 'root' }) -export class IpcService { - async invoke(command: C, ...args: IpcInvocation): Promise> { - try { - return await invoke>(command, args[0] as InvokeArgs | undefined); - } catch (cause) { - throw new IpcError(command, cause); - } - } -} diff --git a/src/app/core/tray/tray.service.spec.ts b/src/app/core/tray/tray.service.spec.ts index 20f63a9..7942d9d 100644 --- a/src/app/core/tray/tray.service.spec.ts +++ b/src/app/core/tray/tray.service.spec.ts @@ -1,32 +1,32 @@ import { TestBed } from '@angular/core/testing'; import { TranslocoService } from '@jsverse/transloco'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { IpcService } from '@core/ipc/ipc.service'; +import { type MockInstance, beforeEach, describe, expect, it, vi } from 'vitest'; +import { commands } from '@core/ipc/bindings'; import { provideTranslocoTesting } from '@testing/provide-transloco-testing'; import { TrayLabels, TrayService } from './tray.service'; describe('TrayService', () => { - let invoke: ReturnType; + // The generated bindings call `invoke` directly, so the command object is what + // a spec substitutes — there is no injectable seam left to replace. + let syncTray: MockInstance; let service: TrayService; /** Labels of the last `sync_tray` call. */ function lastLabels(): TrayLabels { - return invoke.mock.calls.at(-1)?.[1].labels; + return syncTray.mock.calls.at(-1)![0]; } beforeEach(() => { - invoke = vi.fn(async () => undefined); + syncTray = vi.spyOn(commands, 'syncTray').mockResolvedValue(undefined); TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - providers: [provideTranslocoTesting(), { provide: IpcService, useValue: { invoke } }], - }); + TestBed.configureTestingModule({ providers: [provideTranslocoTesting()] }); service = TestBed.inject(TrayService); }); it('pushes the four menu labels, already translated', () => { service.start(); - expect(invoke).toHaveBeenCalledWith('sync_tray', { labels: expect.any(Object) }); + expect(syncTray).toHaveBeenCalled(); // French is the default locale; the native side never holds a string. expect(lastLabels()).toEqual({ open: 'Ouvrir DevBox', @@ -38,18 +38,18 @@ describe('TrayService', () => { it('re-translates the menu when the interface language changes', () => { service.start(); - const before = invoke.mock.calls.length; + const before = syncTray.mock.calls.length; TestBed.inject(TranslocoService).setActiveLang('en'); - expect(invoke.mock.calls.length).toBeGreaterThan(before); + expect(syncTray.mock.calls.length).toBeGreaterThan(before); expect(lastLabels().quit).toBe('Quit DevBox'); }); it('stays silent when there is no tray to talk to', async () => { // Outside Tauri the bridge is absent; the window is still usable and still // closable, so there is nothing to tell the user. - invoke.mockRejectedValue(new Error('no bridge')); + syncTray.mockRejectedValue(new Error('no bridge')); expect(() => service.start()).not.toThrow(); await Promise.resolve(); diff --git a/src/app/core/tray/tray.service.ts b/src/app/core/tray/tray.service.ts index 21d02bd..3c57a7a 100644 --- a/src/app/core/tray/tray.service.ts +++ b/src/app/core/tray/tray.service.ts @@ -1,18 +1,16 @@ import { Injectable, inject } from '@angular/core'; import { TranslocoService } from '@jsverse/transloco'; -import { IpcService } from '@core/ipc/ipc.service'; +import { commands, type TrayLabels as WireTrayLabels } from '@core/ipc/bindings'; /** * Libellés du menu de la barre système, **déjà traduits**. Le natif n'écrit * aucun texte visible : la langue est une préférence du front, et une table de * traductions en Rust serait une seconde source à tenir en phase. + * + * Réexporté depuis les bindings : un libellé ajouté au menu côté Rust manque + * ici à la compilation. */ -export interface TrayLabels { - readonly open: string; - readonly newNote: string; - readonly capture: string; - readonly quit: string; -} +export type TrayLabels = WireTrayLabels; const LABEL_KEYS = ['tray.open', 'tray.newNote', 'tray.capture', 'tray.quit']; @@ -30,7 +28,6 @@ const LABEL_KEYS = ['tray.open', 'tray.newNote', 'tray.capture', 'tray.quit']; */ @Injectable({ providedIn: 'root' }) export class TrayService { - private readonly ipc = inject(IpcService); private readonly transloco = inject(TranslocoService); start(): void { @@ -47,7 +44,9 @@ export class TrayService { */ private async push(labels: TrayLabels): Promise { try { - await this.ipc.invoke('sync_tray', { labels }); + // Seule commande sans `Result` côté Rust, donc sans `unwrap` : elle lève + // directement si le pont est absent. + await commands.syncTray(labels); } catch { // Sans barre système, l'application vit dans sa fenêtre. } diff --git a/src/app/core/updates/updater.service.ts b/src/app/core/updates/updater.service.ts index 30c3868..f7c2292 100644 --- a/src/app/core/updates/updater.service.ts +++ b/src/app/core/updates/updater.service.ts @@ -16,9 +16,8 @@ export type DownloadProgress = number | null; /** * Seul point de passage vers le plugin de mise à jour. * - * `IpcService` couvre les commandes que nous écrivons ; celles-ci appartiennent - * au plugin, ne figurent donc pas dans `IpcContract` et ne peuvent pas passer - * par lui. Le principe reste le même : ni composant ni store n'importe + * `bindings.ts` couvre les commandes que nous écrivons ; celles-ci appartiennent + * au plugin et n'y figurent donc pas. Le principe reste le même : ni composant ni store n'importe * `@tauri-apps/plugin-updater`, ce qui rend le store testable en doublant cette * classe — sans quoi il faudrait un pont Tauri dans jsdom. * diff --git a/src/app/features/notes/data/note.dto.ts b/src/app/features/notes/data/note.dto.ts index ec60a8d..3af4ef4 100644 --- a/src/app/features/notes/data/note.dto.ts +++ b/src/app/features/notes/data/note.dto.ts @@ -1,81 +1,45 @@ +import type { + DisplayNote, + NoteDraft as WireNoteDraft, + NoteFooter as WireNoteFooter, + NoteLifecycle as WireNoteLifecycle, + NotePatch as WireNotePatch, + NoteSection as WireNoteSection, + NotesQuery as WireNotesQuery, + NotesView as WireNotesView, +} from '@core/ipc/bindings'; import { FALLBACK_LANGUAGE, LanguageTag, isLanguageTag } from '@core/language/language.model'; import { Note, NoteDraft, - NoteFilter, NoteFooter, NoteLifecycle, NotePatch, NoteSection, - NoteSectionKey, NotesQuery, NotesView, } from '../model/note.model'; /** - * Représentation transportée sur le pont Tauri. Elle diffère du modèle sur un - * point décisif : **JSON n'a pas de type date**, donc toute `Date` arrive et - * repart en chaîne ISO 8601. + * Représentation transportée sur le pont Tauri, **générée** depuis les structs + * Rust par tauri-specta (`@core/ipc/bindings`). Le reste de l'application ne lit + * jamais `bindings.ts` : elle passe par ces alias, qui gardent le vocabulaire de + * la frontière au même endroit que sa conversion. * - * ⚠️ Côté Rust les structs correspondantes portent - * `#[serde(rename_all = "camelCase")]` (sinon on reçoit `created_at`) et les - * enums à données `#[serde(tag = "kind", …)]` (sinon serde produit - * `{"Expires":{…}}`, que le discriminant TS ne reconnaît pas). Ces attributs - * sont figés par des tests dans `src-tauri/src/domain/`. + * Ce qui subsiste malgré la génération, c'est ce que le générateur ne peut pas + * savoir : **JSON n'a pas de type date**, donc toute `Date` arrive et repart en + * chaîne ISO 8601 ; un `language` est une chaîne libre côté Rust que le front + * restreint à un `LanguageTag`. * - * `footer` et `expiringSoon` sont aplatis dans le même objet : un seul type de - * note côté front. + * `footer` et `expiringSoon` sont aplatis dans le même objet (le `#[serde(flatten)]` + * de `DisplayNote`) : un seul type de note côté front. */ -export interface NoteDto { - readonly id: string; - readonly spaceId: string; - readonly title: string; - readonly language: string; - readonly content: string; - readonly source: string; - readonly tags: readonly string[]; - readonly pinned: boolean; - readonly createdAt: string; - readonly updatedAt: string; - readonly lifecycle: NoteLifecycleDto; - readonly footer: NoteFooterDto; - readonly expiringSoon: boolean; -} - -type NoteLifecycleDto = { readonly kind: 'permanent' } | { readonly kind: 'expires'; readonly at: string }; - -type NoteFooterDto = - | { readonly kind: 'source'; readonly value: string } - | { readonly kind: 'expiry'; readonly at: string } - | { readonly kind: 'age'; readonly at: string }; - -export type NoteDraftDto = Omit; -export type NotePatchDto = Partial; - -export interface NotesQueryDto { - readonly spaceId: string | null; - readonly search: string; - readonly filter: NoteFilter; - readonly tags: readonly string[]; - readonly languages: readonly string[]; - readonly now: string; - readonly tzOffsetMinutes: number; -} - -export interface NotesViewDto { - readonly sections: readonly NoteSectionDto[]; - readonly availableTags: readonly string[]; - readonly availableLanguages: readonly string[]; - readonly isFiltering: boolean; - readonly matched: number; -} +export type NoteDto = DisplayNote; -interface NoteSectionDto { - readonly key: string; - readonly notes: readonly NoteDto[]; - readonly hasExpiringNotes: boolean; - readonly showCreateGhost: boolean; -} +export type NoteDraftDto = WireNoteDraft; +export type NotePatchDto = WireNotePatch; +export type NotesQueryDto = WireNotesQuery; +export type NotesViewDto = WireNotesView; /** Rupture de contrat entre ce que le pont livre et ce que le front sait lire. */ export class ContractError extends Error { @@ -106,19 +70,19 @@ export function toIsoString(date: Date, field: string): string { return date.toISOString(); } -function toLifecycle(dto: NoteLifecycleDto): NoteLifecycle { +function toLifecycle(dto: WireNoteLifecycle): NoteLifecycle { return dto.kind === 'expires' ? { kind: 'expires', at: parseIsoDate(dto.at, 'lifecycle.at') } : { kind: 'permanent' }; } -function toLifecycleDto(lifecycle: NoteLifecycle): NoteLifecycleDto { +function toLifecycleDto(lifecycle: NoteLifecycle): WireNoteLifecycle { return lifecycle.kind === 'expires' ? { kind: 'expires', at: toIsoString(lifecycle.at, 'lifecycle.at') } : { kind: 'permanent' }; } -function toFooter(dto: NoteFooterDto): NoteFooter { +function toFooter(dto: WireNoteFooter): NoteFooter { if (dto.kind === 'source') return { kind: 'source', value: dto.value }; if (dto.kind === 'expiry') return { kind: 'expiry', at: parseIsoDate(dto.at, 'footer.at') }; if (dto.kind === 'age') return { kind: 'age', at: parseIsoDate(dto.at, 'footer.at') }; @@ -160,24 +124,19 @@ export function toNoteDraftDto(draft: NoteDraft): NoteDraftDto { } export function toNotePatchDto(patch: NotePatch): NotePatchDto { - const dto: Record = {}; + const dto: NotePatchDto = {}; // Recopie champ par champ : un `undefined` sérialisé deviendrait `null` côté - // serde et écraserait la valeur existante au lieu de la laisser intacte. - if (patch.spaceId !== undefined) dto['spaceId'] = patch.spaceId; - if (patch.title !== undefined) dto['title'] = patch.title; - if (patch.language !== undefined) dto['language'] = patch.language; - if (patch.content !== undefined) dto['content'] = patch.content; - if (patch.source !== undefined) dto['source'] = patch.source; - if (patch.tags !== undefined) dto['tags'] = [...patch.tags]; - if (patch.pinned !== undefined) dto['pinned'] = patch.pinned; - if (patch.lifecycle !== undefined) dto['lifecycle'] = toLifecycleDto(patch.lifecycle); - return dto as NotePatchDto; -} - -const SECTION_KEYS: readonly NoteSectionKey[] = ['pinned', 'today', 'week', 'older', 'results']; - -function isSectionKey(value: string): value is NoteSectionKey { - return (SECTION_KEYS as readonly string[]).includes(value); + // serde et écraserait la valeur existante au lieu de la laisser intacte. Le + // `#[specta(optional)]` des champs Rust est ce qui rend ces clés omissibles. + if (patch.spaceId !== undefined) dto.spaceId = patch.spaceId; + if (patch.title !== undefined) dto.title = patch.title; + if (patch.language !== undefined) dto.language = patch.language; + if (patch.content !== undefined) dto.content = patch.content; + if (patch.source !== undefined) dto.source = patch.source; + if (patch.tags !== undefined) dto.tags = [...patch.tags]; + if (patch.pinned !== undefined) dto.pinned = patch.pinned; + if (patch.lifecycle !== undefined) dto.lifecycle = toLifecycleDto(patch.lifecycle); + return dto; } export function toNotesQueryDto(query: NotesQuery): NotesQueryDto { @@ -192,13 +151,12 @@ export function toNotesQueryDto(query: NotesQuery): NotesQueryDto { }; } -function toSection(dto: NoteSectionDto): NoteSection { - // Une clé inconnue n'a pas de traduction : elle produirait une section au - // titre vide plutôt qu'une erreur visible. - if (!isSectionKey(dto.key)) { - throw new ContractError('section.key', dto.key); - } - +/** + * Plus de garde sur `key` : `NoteSectionKey` vient des bindings, donc une + * variante ajoutée côté Rust casse cette affectation à la compilation. La garde + * d'exécution ne rattrapait que ce que le compilateur ignorait. + */ +function toSection(dto: WireNoteSection): NoteSection { return { key: dto.key, notes: dto.notes.map(toNote), diff --git a/src/app/features/notes/data/note.dto.view.spec.ts b/src/app/features/notes/data/note.dto.view.spec.ts index b8a8491..58c44b6 100644 --- a/src/app/features/notes/data/note.dto.view.spec.ts +++ b/src/app/features/notes/data/note.dto.view.spec.ts @@ -38,8 +38,9 @@ describe('toNotesView', () => { }); it('drops a language this build does not know rather than failing', () => { - // A rail missing one facet stays usable; unlike an unknown section key, - // which makes a whole part of the canvas unreadable and does throw. + // A rail missing one facet stays usable. Languages are the only wire value + // still narrowed at runtime: Rust types them as a free string, so unlike a + // section key the generated bindings cannot rule an unknown one out. const view = toNotesView({ ...BASE_VIEW, availableLanguages: ['json', 'cobol'] }); expect(view.availableLanguages).toEqual(['json']); diff --git a/src/app/features/notes/data/notes.repository.ts b/src/app/features/notes/data/notes.repository.ts index de9d7f0..e4ca480 100644 --- a/src/app/features/notes/data/notes.repository.ts +++ b/src/app/features/notes/data/notes.repository.ts @@ -1,12 +1,13 @@ -import { Injectable, inject } from '@angular/core'; -import { IpcService } from '@core/ipc/ipc.service'; +import { Injectable } from '@angular/core'; +import { commands } from '@core/ipc/bindings'; +import { unwrap } from '@core/ipc/ipc.error'; import { Note, NoteDraft, NotePatch, NotesQuery, NotesView } from '../model/note.model'; import { toNote, toNoteDraftDto, toNotePatchDto, toNotesQueryDto, toNotesView } from './note.dto'; /** * Point d'accès aux notes : ni composant ni store ne touche une source de - * données autrement. Les noms d'arguments et les types de retour viennent - * d'`IpcContract`, qui les tient alignés sur les signatures Rust. + * données autrement. Les signatures viennent de `bindings.ts`, généré depuis le + * Rust — un argument mal nommé ou un type qui a bougé est une erreur de build. * * `query` renvoie une **vue déjà filtrée et regroupée** ; il n'existe pas de * méthode rendant la liste brute, précisément pour qu'aucun appelant ne soit @@ -15,21 +16,19 @@ import { toNote, toNoteDraftDto, toNotePatchDto, toNotesQueryDto, toNotesView } */ @Injectable({ providedIn: 'root' }) export class NotesRepository { - private readonly ipc = inject(IpcService); - async query(query: NotesQuery): Promise { - return toNotesView(await this.ipc.invoke('query_notes', { query: toNotesQueryDto(query) })); + return toNotesView(unwrap('query_notes', await commands.queryNotes(toNotesQueryDto(query)))); } async create(draft: NoteDraft): Promise { - return toNote(await this.ipc.invoke('create_note', { draft: toNoteDraftDto(draft) })); + return toNote(unwrap('create_note', await commands.createNote(toNoteDraftDto(draft)))); } async update(id: string, patch: NotePatch): Promise { - return toNote(await this.ipc.invoke('update_note', { id, patch: toNotePatchDto(patch) })); + return toNote(unwrap('update_note', await commands.updateNote(id, toNotePatchDto(patch)))); } async delete(id: string): Promise { - await this.ipc.invoke('delete_note', { id }); + unwrap('delete_note', await commands.deleteNote(id)); } } diff --git a/src/app/features/notes/data/spaces.repository.ts b/src/app/features/notes/data/spaces.repository.ts index 39178f1..b4b129d 100644 --- a/src/app/features/notes/data/spaces.repository.ts +++ b/src/app/features/notes/data/spaces.repository.ts @@ -1,5 +1,6 @@ -import { Injectable, inject } from '@angular/core'; -import { IpcService } from '@core/ipc/ipc.service'; +import { Injectable } from '@angular/core'; +import { commands } from '@core/ipc/bindings'; +import { unwrap } from '@core/ipc/ipc.error'; import { Space, SpaceDraft } from '../model/space.model'; /** @@ -14,22 +15,20 @@ import { Space, SpaceDraft } from '../model/space.model'; */ @Injectable({ providedIn: 'root' }) export class SpacesRepository { - private readonly ipc = inject(IpcService); - async loadAll(): Promise { - return this.ipc.invoke('list_spaces'); + return unwrap('list_spaces', await commands.listSpaces()); } async create(draft: SpaceDraft): Promise { - return this.ipc.invoke('create_space', { draft }); + return unwrap('create_space', await commands.createSpace(draft)); } async rename(id: string, draft: SpaceDraft): Promise { - return this.ipc.invoke('rename_space', { id, draft }); + return unwrap('rename_space', await commands.renameSpace(id, draft)); } /** `targetSpaceId` recueille les notes de l'espace supprimé. */ async delete(id: string, targetSpaceId: string): Promise { - await this.ipc.invoke('delete_space', { id, targetSpaceId }); + unwrap('delete_space', await commands.deleteSpace(id, targetSpaceId)); } } From 86dab6241169107cca8704d0e19f255a2ea79337 Mon Sep 17 00:00:00 2001 From: Valentin MILLET Date: Sat, 8 Aug 2026 16:03:45 +0200 Subject: [PATCH 2/6] Migrate from rusqlite to Diesel, refactor storage layer, adopt embedded migrations with legacy compatibility, and enhance schema evolution logic. --- CLAUDE.md | 9 +- README.md | 8 +- docs/architecture.md | 55 +- src-tauri/Cargo.lock | 183 +++-- src-tauri/Cargo.toml | 8 +- .../2026-07-25-000001_initial/down.sql | 3 + .../2026-07-25-000001_initial/up.sql | 39 + .../2026-07-25-000002_fold_tag_case/down.sql | 16 + .../2026-07-25-000002_fold_tag_case/up.sql | 19 + .../2026-07-25-000003_index_language/down.sql | 1 + .../2026-07-25-000003_index_language/up.sql | 7 + src-tauri/src/commands/error.rs | 22 +- src-tauri/src/commands/mod.rs | 22 +- src-tauri/src/commands/notes.rs | 8 +- src-tauri/src/commands/spaces.rs | 12 +- src-tauri/src/storage/mod.rs | 480 +++++++----- src-tauri/src/storage/notes.rs | 702 +++++++++--------- src-tauri/src/storage/schema.rs | 48 ++ src-tauri/src/storage/spaces.rs | 331 +++++---- 19 files changed, 1171 insertions(+), 802 deletions(-) create mode 100644 src-tauri/migrations/2026-07-25-000001_initial/down.sql create mode 100644 src-tauri/migrations/2026-07-25-000001_initial/up.sql create mode 100644 src-tauri/migrations/2026-07-25-000002_fold_tag_case/down.sql create mode 100644 src-tauri/migrations/2026-07-25-000002_fold_tag_case/up.sql create mode 100644 src-tauri/migrations/2026-07-25-000003_index_language/down.sql create mode 100644 src-tauri/migrations/2026-07-25-000003_index_language/up.sql create mode 100644 src-tauri/src/storage/schema.rs diff --git a/CLAUDE.md b/CLAUDE.md index cd1383c..c05e7bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,11 @@ The **notes feature is complete end to end**: front-end (spaces with creation, r **Three Rust layers, dependencies pointing one way: `commands/ → domain/ ← storage/`.** -- `src-tauri/src/domain/` — model and business rules. Knows neither rusqlite nor Tauri, so its tests run without a database. -- `src-tauri/src/storage/` — SQLite (`rusqlite`, `bundled`, database in `app_data_dir()`). SQL only, zero business rules. +- `src-tauri/src/domain/` — model and business rules. Knows neither Diesel nor Tauri, so its tests run without a database. +- `src-tauri/src/storage/` — SQLite through Diesel (`libsqlite3-sys` `bundled`, database in `app_data_dir()`). Queries only, zero business rules. - `src-tauri/src/commands/` — Tauri adapters: validate, lock, delegate, translate the error. A command that grows means a rule landed in the wrong place. -Two greps guard the direction, and are worth running after any structural change: `grep -rn "rusqlite\|tauri::" src-tauri/src/domain/` and `grep -rn "use crate::commands" src-tauri/src/storage/` must both come back empty. `docs/architecture.md` has the details. +Two greps guard the direction, and are worth running after any structural change: `grep -rn "diesel\|tauri::" src-tauri/src/domain/` and `grep -rn "use crate::commands" src-tauri/src/storage/` must both come back empty. `docs/architecture.md` has the details. **The front-end is filed by subject, not by technical nature.** A feature owns its `data/`, `model/`, `state/` and `ui/` — `features/notes/` holds the DTOs, the repositories, the models, both stores and every notes component, so deleting the folder deletes the feature. `core/` is only what a second, unrelated tool would inject verbatim (`ipc`, `i18n`, `errors`, `time`, `preferences`, `updates`, `app-info`, `language`), one folder per subject with a service and its store together — there is no `core/stores/`. `shared/` is a presentation kit whose components **inject nothing**; anything that injects and frames the app belongs to `layout/`. Adding the hashing tool must not add a file under `core/`. @@ -74,7 +74,8 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **Syntax highlighting is highlight.js, front-side, in exactly one module.** `shared/ui/code-viewer/highlighter.ts` imports grammars **one by one** (`highlight.js/lib/languages/…`), never the default bundle. It colours the whole block — that's what handles multi-line comments and strings — then re-splits the output with `splitHighlightedLines`, which reopens the tag stack across each newline. The `.hljs-*` theme lives in the **global** `src/styles/_code-theme.scss`: injected by `[innerHTML]`, it carries no `_ngcontent` attribute, so a component-scoped rule would never match. - **Tag normalisation lives in `domain::rules::normalize_tags`, and only there.** Trim, strip leading `#`, drop blanks, collapse case-insensitive duplicates. The front sends the raw string. `storage::notes::replace_tags` calls it and sorts the result to match what a read gives back, or a note's tags reorder themselves on the next reload. `note_tags.tag` is `COLLATE NOCASE` (migration 2) so the folding extends across notes, not just within one. - **A `computed` feeding a `resource` needs an `equal` comparator.** `resource` compares params by identity. `NotesStore.queryParams` returns a fresh object literal and reads `clock.now()`: without `sameQueryParams`, every 30 s tick fired a full `query_notes` round trip, invisible behind the retained view. -- **Migrations are append-only.** The SQLite schema is versioned by `PRAGMA user_version` in `src-tauri/src/storage/mod.rs`. Changing the model means a new `MIGRATION_N` and a new branch in `migrate` — never editing `MIGRATION_1`, which has already run on existing installs. Deleting the database file is a legitimate reset during development (`app_data_dir()/devbox.sqlite3`). +- **Migrations are append-only.** They are SQL files under `src-tauri/migrations/`, embedded by `embed_migrations!` and tracked in `__diesel_schema_migrations`. Changing the model means a new `YYYY-MM-DD-HHMMSS_name/` directory with an `up.sql` — never editing a shipped one, it has already run on existing installs. `storage::adopt_legacy_history` bridges databases still versioned by the old `PRAGMA user_version` (1..3): it marks the matching migrations as applied and zeroes the pragma, so nothing is replayed. Deleting the database file is a legitimate reset during development (`app_data_dir()/devbox.sqlite3`). +- **`storage/schema.rs` is hand-written, not `diesel print-schema`.** Generating it would make `cargo check` depend on an up-to-date database outside the repo. Adding a column means editing **both** the migration SQL and this file; `check_for_backend` on `NoteRow` turns a divergence into a compile error. Diesel does not model the `CHECK`s, the `ON DELETE CASCADE`s or the `NOCASE` collation — those live in the migration SQL and are simply obeyed. Where a collation must be applied to an expression rather than a column (`spaces.name`), the query drops to a `diesel::dsl::sql` fragment; that is deliberate, not a gap to tidy up. - **`PRAGMA foreign_keys` is per connection, not per database.** It's set in `storage::configure`; without it the `ON DELETE CASCADE` clauses in the schema are inert and deleting a note leaves its tags behind. - **Zoneless.** Every component is `OnPush` and state is signal-based; derived state is `computed()`, never a manually maintained signal. diff --git a/README.md b/README.md index feb676f..60a453e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A developer's Swiss Army knife for the desktop: a notes/snippets manager, plus u > **Status** — the notes feature is complete end to end. The UI has no mock data left, every > read and write crosses the `invoke()` bridge, and the Rust side persists to an embedded > SQLite database. Business rules live in `src-tauri/src/domain/`, which depends on neither -> rusqlite nor Tauri. `crypto` and `formatters` are documented placeholders, not yet built. +> Diesel nor Tauri. `crypto` and `formatters` are documented placeholders, not yet built. ## Prerequisites @@ -60,7 +60,7 @@ docs/ Architecture notes and UI mockup ``` Dependencies point one way: `commands/ → domain/ ← storage/`. Two greps keep it honest — -`grep -rn "rusqlite\|tauri::" src-tauri/src/domain/` and +`grep -rn "diesel\|tauri::" src-tauri/src/domain/` and `grep -rn "use crate::commands" src-tauri/src/storage/` must both come back empty. ## Documentation @@ -80,8 +80,8 @@ Dependencies point one way: `commands/ → domain/ ← storage/`. Two greps keep - [x] Full note editing: content, format, tags, pin, deletion - [x] Spaces: notes carry a `spaceId`, the switcher filters on it and can create a space - [x] ESLint + Prettier, with template accessibility rules -- [x] Persistence: embedded SQLite (`rusqlite`, `bundled`) with versioned, append-only - migrations +- [x] Persistence: embedded SQLite through Diesel (`libsqlite3-sys` `bundled`) with + append-only, embedded migrations - [x] Business rules isolated in `src-tauri/src/domain/`, testable without a database - [x] Rust tests, clippy (`deny(clippy::all)`) and rustfmt - [ ] Renaming and deleting a space — needs a decision on what happens to its notes diff --git a/docs/architecture.md b/docs/architecture.md index ff30aea..edf9107 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ neither one defines it. (Before the domain layer existed, the model lived in `co Two greps enforce it, and are worth running after any structural change: ```bash -grep -rn "rusqlite\|tauri::" src-tauri/src/domain/ # must be empty +grep -rn "diesel\|tauri::" src-tauri/src/domain/ # must be empty grep -rn "use crate::commands" src-tauri/src/storage/ # must be empty ``` @@ -79,7 +79,7 @@ Serde attributes sit on the domain types rather than on a separate DTO family. A second set of types and their mapping would cost more than it protects. Each of these types also derives `specta::Type`, which is what lets tauri-specta generate the front-end's `bindings.ts` from them — a derive from a plain library crate, so `domain/` still knows -neither Tauri nor rusqlite and the direction grep stays clean. +neither Tauri nor Diesel and the direction grep stays clean. ## Front-end @@ -709,21 +709,37 @@ re-cut every section on a different day. ## Persistence (Rust) -Storage is **SQLite**, embedded through `rusqlite` with the `bundled` feature — SQLite is -compiled from source and statically linked, so nothing has to be installed or shipped -alongside the executable. The database file lives in Tauri's `app_data_dir()`. +Storage is **SQLite**, queried through **Diesel** and embedded via `libsqlite3-sys` with the +`bundled` feature — SQLite is compiled from source and statically linked, so nothing has to be +installed or shipped alongside the executable. The database file lives in Tauri's +`app_data_dir()`. - **Layering.** `storage::notes` and `storage::spaces` are plain functions taking a - `&Connection`; the `#[tauri::command]`s sit on top. That is what makes persistence testable - against `Connection::open_in_memory()` without launching Tauri. This layer holds **no - business rule** — it reads and writes the model defined in `domain/`, which it depends on. -- **Concurrency.** A rusqlite `Connection` is not `Sync`. A single connection is shared as - `tauri::State` (`Db = Mutex`), registered with `.manage()` in `lib.rs` — - never a global. Overlapping commands serialise on that mutex. -- **Migrations.** The schema is versioned by `PRAGMA user_version`. Evolving the model means - adding a `MIGRATION_N` constant and a branch in `migrate` — never editing a shipped - migration, it has already run on user machines. Each migration is atomic. A database written - by a newer build is refused rather than misread. + `&mut SqliteConnection`; the `#[tauri::command]`s sit on top. That is what makes persistence + testable against `SqliteConnection::establish(":memory:")` without launching Tauri. This + layer holds **no business rule** — it reads and writes the model defined in `domain/`, which + it depends on. +- **`storage/schema.rs` is the typed mirror of the schema**, written by hand rather than + produced by `diesel print-schema`, which would make `cargo check` depend on an up-to-date + database sitting outside the repository. What it deliberately does not model — `CHECK` + constraints, `ON DELETE CASCADE`, and the `NOCASE` collation on `note_tags.tag` — stays in + the migration SQL. Diesel obeys those; it does not own them. +- **Concurrency.** A `SqliteConnection` is not `Sync`, and Diesel takes it exclusively for + every query, reads included. A single connection is shared as `tauri::State` + (`Db = Mutex`), registered with `.manage()` in `lib.rs` — never a global. + Overlapping commands serialise on that mutex, as they already did; the `&mut` changes the + signatures, not the concurrency. +- **Migrations.** They live as SQL files in `src-tauri/migrations/`, are compiled into the + binary by `embed_migrations!`, and are tracked in the `__diesel_schema_migrations` table. + Evolving the model means adding a `YYYY-MM-DD-HHMMSS_name/` directory — never editing a + shipped migration, it has already run on user machines. Each migration is atomic. A database + carrying a migration this binary does not know is refused rather than misread. +- **The legacy `PRAGMA user_version` history is adopted, not replayed.** The schema used to be + versioned by that pragma (values 1 to 3). `storage::adopt_legacy_history` marks the matching + embedded migrations as already applied and zeroes the pragma, so an existing install neither + re-runs `CREATE TABLE spaces` nor keeps a second, drifting source of truth. A pre-Diesel + binary reopening such a database now fails loudly at startup instead of writing into a schema + it believes it understands. - **Schema choices that made filtering movable to the back-end.** `lifecycle` is split into `lifecycle_kind` + `lifecycle_expires_at` columns rather than stored as JSON, and tags live in their own `note_tags` table rather than in a serialised column. Both exist so that @@ -736,7 +752,10 @@ alongside the executable. The database file lives in Tauri's `app_data_dir()`. `created_at`. The section answers "when was this note born", the order within it answers "which did I touch last", so an old note reopened today tops the "older" section. - **Querying splits the work by what each tool does well.** SQL handles what it indexes — - space, pin state, lifecycle, language, and tag membership through `EXISTS` on `note_tags`. + space, pin state, lifecycle, language, and tag membership through a `note_tags` subquery + (`notes::id.eq_any(...)`). The conditional criteria are assembled on a Diesel `into_boxed()` + query, which is what replaced hand-numbered `?N` placeholders and their bound-parameter + bookkeeping. Full-text matching is done **in Rust** (`domain::search`), because SQLite's `LOWER()` only folds ASCII without ICU, so `Étape` would not match `étape`. Grouping is `domain::sections`, which @@ -747,11 +766,11 @@ alongside the executable. The database file lives in Tauri's `app_data_dir()`. tags are sorted to match what a read gives back — otherwise a note's tags would reorder themselves on the next reload. - **Tag case folds at the storage level too.** `note_tags.tag` is `COLLATE NOCASE` - (migration 2). Without it `normalize` only deduplicated _within_ one note: `Urgent` and + (the `fold_tag_case` migration). Without it `normalize` only deduplicated _within_ one note: `Urgent` and `urgent` carried by two different notes produced two facets in the rail, of which `tag IN (…)` — running in BINARY — matched only one, while the text search confused them. Three behaviours for one concept. -- **`notes.language` is indexed** (migration 3), since it became a filtering facet: both +- **`notes.language` is indexed** (the `index_language` migration), since it became a filtering facet: both `language IN (…)` and the `SELECT DISTINCT language` that feeds the rail would otherwise scan the table on every query. No `CHECK` constraint on the column, though — the list of known languages lives in `domain::language` and moves between versions; freezing it in the diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3a68c36..63eb455 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -675,14 +675,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -698,13 +722,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -766,7 +801,9 @@ name = "devbox" version = "0.1.0" dependencies = [ "chrono", - "rusqlite", + "diesel", + "diesel_migrations", + "libsqlite3-sys", "serde", "serde_json", "specta", @@ -783,6 +820,52 @@ dependencies = [ "uuid", ] +[[package]] +name = "diesel" +version = "2.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -881,6 +964,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -890,6 +979,20 @@ dependencies = [ "serde", ] +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -932,6 +1035,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "embed-resource" version = "3.0.11" @@ -1032,18 +1141,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.5.0" @@ -1604,18 +1701,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "hashlink" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" -dependencies = [ - "hashbrown 0.17.1", -] [[package]] name = "heck" @@ -2231,6 +2316,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -3112,21 +3218,6 @@ dependencies = [ "thiserror 2.0.19", ] -[[package]] -name = "rusqlite" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" -dependencies = [ - "bitflags 2.13.1", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -3482,7 +3573,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.119", @@ -4209,7 +4300,7 @@ version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" dependencies = [ - "darling", + "darling 0.23.0", "heck 0.5.0", "proc-macro2", "quote", @@ -4952,7 +5043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix", "smallvec", "wayland-sys", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6383c7f..3628949 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,13 +30,19 @@ specta = "=2.0.0-rc.25" specta-typescript = "=0.0.12" serde = { version = "1", features = ["derive"] } serde_json = "1.0.151" -rusqlite = { version = "0.40.1", features = ["bundled"] } +diesel = { version = "2.3.12", features = ["sqlite"] } +diesel_migrations = { version = "2.3.2", features = ["sqlite"] } +# Dépendance directe uniquement pour `bundled` : diesel tire `libsqlite3-sys` +# sans l'activer, et sans elle il faudrait un SQLite système sur la machine de +# build comme sur celle de l'utilisateur. +libsqlite3-sys = { version = "0.38.1", features = ["bundled"] } uuid = { version = "1.24.0", features = ["v4"] } chrono = "0.4.45" tauri-plugin-process = "2" tauri-plugin-opener = "2" tauri-plugin-store = "2" tauri-plugin-clipboard-manager = "2" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/migrations/2026-07-25-000001_initial/down.sql b/src-tauri/migrations/2026-07-25-000001_initial/down.sql new file mode 100644 index 0000000..f201b2a --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000001_initial/down.sql @@ -0,0 +1,3 @@ +DROP TABLE note_tags; +DROP TABLE notes; +DROP TABLE spaces; diff --git a/src-tauri/migrations/2026-07-25-000001_initial/up.sql b/src-tauri/migrations/2026-07-25-000001_initial/up.sql new file mode 100644 index 0000000..87e0b82 --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000001_initial/up.sql @@ -0,0 +1,39 @@ +-- Schéma initial. Deux choix rendent le filtrage requêtable : `lifecycle` +-- éclaté en deux colonnes plutôt qu'en JSON, et les tags dans leur propre table +-- plutôt qu'en colonne sérialisée. + +CREATE TABLE spaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL +); + +-- `spaces::create` vérifie l'unicité pour produire une erreur lisible, cet index +-- la garantit même si une écriture passait à côté. NOCASE ne replie que l'ASCII. +CREATE UNIQUE INDEX spaces_name_unique ON spaces (name COLLATE NOCASE); + +CREATE TABLE notes ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE, + title TEXT NOT NULL, + language TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL, + pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + lifecycle_kind TEXT NOT NULL CHECK (lifecycle_kind IN ('permanent', 'expires')), + lifecycle_expires_at TEXT, + -- Garantit que la lecture peut reconstruire l'enum sans cas ambigu. + CHECK ((lifecycle_kind = 'expires') = (lifecycle_expires_at IS NOT NULL)) +); + +CREATE INDEX notes_space_id ON notes (space_id); +CREATE INDEX notes_updated_at ON notes (updated_at DESC); + +CREATE TABLE note_tags ( + note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (note_id, tag) +); + +CREATE INDEX note_tags_tag ON note_tags (tag); diff --git a/src-tauri/migrations/2026-07-25-000002_fold_tag_case/down.sql b/src-tauri/migrations/2026-07-25-000002_fold_tag_case/down.sql new file mode 100644 index 0000000..c528e58 --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000002_fold_tag_case/down.sql @@ -0,0 +1,16 @@ +-- La collation ne s'annule pas plus qu'elle ne s'applique : table recréée dans +-- l'autre sens. Les tags fusionnés par la migration montante ne se rescindent +-- pas — l'information a été perdue là, pas ici. + +CREATE TABLE note_tags_v1 ( + note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (note_id, tag) +); + +INSERT OR IGNORE INTO note_tags_v1 (note_id, tag) SELECT note_id, tag FROM note_tags; + +DROP TABLE note_tags; +ALTER TABLE note_tags_v1 RENAME TO note_tags; + +CREATE INDEX note_tags_tag ON note_tags (tag); diff --git a/src-tauri/migrations/2026-07-25-000002_fold_tag_case/up.sql b/src-tauri/migrations/2026-07-25-000002_fold_tag_case/up.sql new file mode 100644 index 0000000..697f41a --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000002_fold_tag_case/up.sql @@ -0,0 +1,19 @@ +-- Replie la casse des tags **entre** notes, là où `rules::normalize_tags` ne la +-- repliait qu'au sein d'une note : `Urgent` et `urgent` produisaient deux +-- facettes dans le rail, dont `tag IN (…)` n'en retrouvait qu'une. +-- +-- La collation d'une colonne ne s'altère pas, d'où la table recréée ; +-- `INSERT OR IGNORE` absorbe les doublons que la nouvelle clé primaire fusionne. + +CREATE TABLE note_tags_v2 ( + note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, + tag TEXT NOT NULL COLLATE NOCASE, + PRIMARY KEY (note_id, tag) +); + +INSERT OR IGNORE INTO note_tags_v2 (note_id, tag) SELECT note_id, tag FROM note_tags; + +DROP TABLE note_tags; +ALTER TABLE note_tags_v2 RENAME TO note_tags; + +CREATE INDEX note_tags_tag ON note_tags (tag); diff --git a/src-tauri/migrations/2026-07-25-000003_index_language/down.sql b/src-tauri/migrations/2026-07-25-000003_index_language/down.sql new file mode 100644 index 0000000..c97e14e --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000003_index_language/down.sql @@ -0,0 +1 @@ +DROP INDEX notes_language; diff --git a/src-tauri/migrations/2026-07-25-000003_index_language/up.sql b/src-tauri/migrations/2026-07-25-000003_index_language/up.sql new file mode 100644 index 0000000..a9a953a --- /dev/null +++ b/src-tauri/migrations/2026-07-25-000003_index_language/up.sql @@ -0,0 +1,7 @@ +-- Index sur le langage, devenu une facette de filtrage. Sans lui, +-- `language IN (…)` et le `SELECT DISTINCT` du rail balaient tout. +-- +-- Pas de `CHECK` sur la colonne : la liste des langages vit dans le domaine et +-- bouge d'une version à l'autre — la figer imposerait une migration par ajout. + +CREATE INDEX notes_language ON notes (language); diff --git a/src-tauri/src/commands/error.rs b/src-tauri/src/commands/error.rs index 3fa1a84..bafd726 100644 --- a/src-tauri/src/commands/error.rs +++ b/src-tauri/src/commands/error.rs @@ -96,11 +96,12 @@ impl From for AppError { StorageError::DuplicateSpaceName(name) => { Self::with(ErrorCode::DuplicateSpaceName, detail, "name", &name) } - // Inatteignable par le pont (voir [`ErrorCode`]) ; `Storage` reste - // honnête et le `detail` porte déjà la version en clair. - StorageError::SchemaTooRecent(_) | StorageError::Sqlite(_) => { - Self::new(ErrorCode::Storage, detail) - } + // Les deux premières sont inatteignables par le pont (voir + // [`ErrorCode`]) ; `Storage` reste honnête et le `detail` porte déjà + // la version en clair. + StorageError::SchemaTooRecent(_) + | StorageError::Migration(_) + | StorageError::Sqlite(_) => Self::new(ErrorCode::Storage, detail), } } } @@ -140,7 +141,8 @@ mod tests { StorageError::NoteNotFound("n-1".to_string()), StorageError::SpaceNotFound("s-1".to_string()), StorageError::DuplicateSpaceName("Perso".to_string()), - StorageError::SchemaTooRecent(9), + StorageError::SchemaTooRecent("2099-01-01-000000".to_string()), + StorageError::Migration("base verrouillée".to_string()), ]; for error in errors { @@ -166,11 +168,13 @@ mod tests { fn a_schema_too_recent_degrades_to_storage_rather_than_leaking_a_dead_code() { // It cannot cross the bridge (it aborts startup), so the front has no // branch for it — `storage` is the honest code, and the detail carries - // the version in plain text. - let error = AppError::from(StorageError::SchemaTooRecent(9)); + // the offending migration in plain text. + let error = AppError::from(StorageError::SchemaTooRecent( + "2099-01-01-000000".to_string(), + )); assert!(matches!(error.code, ErrorCode::Storage)); - assert!(error.detail.contains('9')); + assert!(error.detail.contains("2099-01-01-000000")); } #[test] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 02c9166..0823e99 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,26 +17,34 @@ use error::AppError; /// Verrou sur la connexion partagée. Un mutex empoisonné signifie qu'une /// commande a paniqué en le tenant : mieux vaut le dire que paniquer à nouveau. -fn lock(db: &Db) -> Result, AppError> { +/// +/// Le garde est rendu **mutable** : Diesel prend la connexion en exclusif à +/// chaque requête, y compris en lecture. +fn lock(db: &Db) -> Result, AppError> { db.lock().map_err(|_| AppError::storage_unavailable()) } #[cfg(test)] mod tests { use super::*; + use diesel::prelude::*; use error::ErrorCode; use std::sync::Mutex; + fn in_memory() -> Db { + Mutex::new(diesel::SqliteConnection::establish(":memory:").unwrap()) + } + #[test] fn a_healthy_connection_is_handed_over() { - let db: Db = Mutex::new(rusqlite::Connection::open_in_memory().unwrap()); + let db = in_memory(); assert!(lock(&db).is_ok()); } #[test] fn a_poisoned_connection_is_reported_instead_of_panicking_again() { - let db: Db = Mutex::new(rusqlite::Connection::open_in_memory().unwrap()); + let db = in_memory(); // Poison it the way production would: a panic while the guard is held. // The hook is silenced so a deliberate panic does not look like a crash. @@ -48,9 +56,13 @@ mod tests { })); std::panic::set_hook(hook); - let error = lock(&db).unwrap_err(); + // `unwrap_err()` would need the guard to be `Debug`, which + // `SqliteConnection` is not; and `unwrap()` in `lock` itself would take + // the whole process down on the next command. + let Err(error) = lock(&db) else { + panic!("un mutex empoisonné doit être signalé, pas rendu"); + }; - // `unwrap()` here would take the whole process down on the next command. assert!(matches!(error.code, ErrorCode::StorageUnavailable)); } } diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index fbb99fc..b353b71 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -19,8 +19,8 @@ use crate::storage::{self, Db}; #[tauri::command] #[specta::specta] pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result { - let connection = lock(&db)?; - let (notes, facets) = storage::notes::fetch(&connection, &query)?; + let mut connection = lock(&db)?; + let (notes, facets) = storage::notes::fetch(&mut connection, &query)?; Ok(view::build(notes, facets, &query)?) } @@ -57,7 +57,7 @@ pub fn update_note( #[tauri::command] #[specta::specta] pub fn delete_note(id: String, db: State<'_, Db>) -> Result<(), AppError> { - let connection = lock(&db)?; + let mut connection = lock(&db)?; - Ok(storage::notes::delete(&connection, &id)?) + Ok(storage::notes::delete(&mut connection, &id)?) } diff --git a/src-tauri/src/commands/spaces.rs b/src-tauri/src/commands/spaces.rs index 55f5a41..de8c20d 100644 --- a/src-tauri/src/commands/spaces.rs +++ b/src-tauri/src/commands/spaces.rs @@ -19,9 +19,9 @@ use crate::storage::{self, Db}; #[tauri::command] #[specta::specta] pub fn list_spaces(db: State<'_, Db>) -> Result, AppError> { - let connection = lock(&db)?; + let mut connection = lock(&db)?; - Ok(storage::spaces::list(&connection)?) + Ok(storage::spaces::list(&mut connection)?) } /// Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. @@ -32,9 +32,9 @@ pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result) -> Result) -> Result { let name = draft.validated_name()?; - let connection = lock(&db)?; + let mut connection = lock(&db)?; - Ok(storage::spaces::rename(&connection, &id, &name)?) + Ok(storage::spaces::rename(&mut connection, &id, &name)?) } /// Supprime un espace après avoir transféré ses notes vers `target_space_id`. diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index b66f808..760881b 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -1,14 +1,21 @@ -//! Persistance : SQLite embarqué (`rusqlite`, feature `bundled`, base dans -//! `app_data_dir()`). Ouverture, configuration et migrations ici ; les lectures -//! et écritures dans `storage::notes` et `storage::spaces`, sous forme de -//! fonctions prenant une `&Connection` — d'où des tests sur base en mémoire, -//! sans lancer Tauri. **Aucune règle métier** : elles sont dans `crate::domain`. +//! Persistance : SQLite embarqué (Diesel, `libsqlite3-sys` en `bundled`, base +//! dans `app_data_dir()`). Ouverture, configuration et migrations ici ; les +//! lectures et écritures dans `storage::notes` et `storage::spaces`, sous forme +//! de fonctions prenant une `&mut SqliteConnection` — d'où des tests sur base en +//! mémoire, sans lancer Tauri. **Aucune règle métier** : elles sont dans +//! `crate::domain`. //! -//! ⚠️ **Les migrations sont append-only.** Le schéma est versionné par -//! `PRAGMA user_version` : faire évoluer le modèle = ajouter un `MIGRATION_N` et -//! une branche dans [`migrate`], jamais modifier une migration déjà livrée. +//! ⚠️ **Les migrations sont append-only.** Elles vivent dans `src-tauri/migrations/`, +//! embarquées dans le binaire par [`embed_migrations!`] et suivies par la table +//! `__diesel_schema_migrations` : faire évoluer le modèle = ajouter un dossier +//! `AAAA-MM-JJ-HHMMSS_nom/`, jamais modifier une migration déjà livrée. +//! +//! Le `&mut` est imposé par Diesel, qui prend la connexion en exclusif à chaque +//! requête. Il ne change rien à la concurrence réelle : le mutex de [`Db`] la +//! sérialisait déjà. pub mod notes; +pub mod schema; pub mod spaces; use std::fmt; @@ -16,7 +23,12 @@ use std::path::Path; use std::sync::Mutex; use chrono::{SecondsFormat, Utc}; -use rusqlite::Connection; +use diesel::connection::SimpleConnection; +use diesel::migration::MigrationSource; +use diesel::prelude::*; +use diesel::sql_types::{Integer, Text}; +use diesel::sqlite::Sqlite; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; /// Instant courant en ISO 8601 UTC, ex. `2026-07-25T09:12:00.000Z`. La /// milliseconde n'est pas décorative : sans elle, deux notes modifiées dans la @@ -25,103 +37,19 @@ pub fn now_iso() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) } -/// Connexion unique partagée via `tauri::State` : `Connection` n'est pas `Sync`, -/// et deux commandes qui se chevauchent se sérialisent sur ce mutex. -pub type Db = Mutex; +/// Connexion unique partagée via `tauri::State` : `SqliteConnection` n'est pas +/// `Sync`, et deux commandes qui se chevauchent se sérialisent sur ce mutex. +pub type Db = Mutex; pub const DB_FILE_NAME: &str = "devbox.sqlite3"; -/// Version de schéma attendue par ce binaire. -const SCHEMA_VERSION: i32 = 3; - -/// Schéma initial. Deux choix rendent le filtrage requêtable : `lifecycle` -/// éclaté en deux colonnes plutôt qu'en JSON, et les tags dans leur propre table -/// plutôt qu'en colonne sérialisée. -const MIGRATION_1: &str = r" -BEGIN; - -CREATE TABLE spaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL -); - --- `spaces::create` vérifie l'unicité pour produire une erreur lisible, cet index --- la garantit même si une écriture passait à côté. NOCASE ne replie que l'ASCII. -CREATE UNIQUE INDEX spaces_name_unique ON spaces (name COLLATE NOCASE); - -CREATE TABLE notes ( - id TEXT PRIMARY KEY, - space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE, - title TEXT NOT NULL, - language TEXT NOT NULL, - content TEXT NOT NULL, - source TEXT NOT NULL, - pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - lifecycle_kind TEXT NOT NULL CHECK (lifecycle_kind IN ('permanent', 'expires')), - lifecycle_expires_at TEXT, - -- Garantit que la lecture peut reconstruire l'enum sans cas ambigu. - CHECK ((lifecycle_kind = 'expires') = (lifecycle_expires_at IS NOT NULL)) -); - -CREATE INDEX notes_space_id ON notes (space_id); -CREATE INDEX notes_updated_at ON notes (updated_at DESC); - -CREATE TABLE note_tags ( - note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, - tag TEXT NOT NULL, - PRIMARY KEY (note_id, tag) -); - -CREATE INDEX note_tags_tag ON note_tags (tag); - -PRAGMA user_version = 1; - -COMMIT; -"; - -/// Replie la casse des tags **entre** notes, là où `rules::normalize_tags` ne la -/// repliait qu'au sein d'une note : `Urgent` et `urgent` produisaient deux -/// facettes dans le rail, dont `tag IN (…)` n'en retrouvait qu'une. -/// -/// La collation d'une colonne ne s'altère pas, d'où la table recréée ; -/// `INSERT OR IGNORE` absorbe les doublons que la nouvelle clé primaire fusionne. -const MIGRATION_2: &str = r" -BEGIN; - -CREATE TABLE note_tags_v2 ( - note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE, - tag TEXT NOT NULL COLLATE NOCASE, - PRIMARY KEY (note_id, tag) -); - -INSERT OR IGNORE INTO note_tags_v2 (note_id, tag) SELECT note_id, tag FROM note_tags; - -DROP TABLE note_tags; -ALTER TABLE note_tags_v2 RENAME TO note_tags; - -CREATE INDEX note_tags_tag ON note_tags (tag); - -PRAGMA user_version = 2; - -COMMIT; -"; - -/// Index sur le langage, devenu une facette de filtrage. Sans lui, -/// `language IN (…)` et le `SELECT DISTINCT` du rail balaient tout. -/// -/// Pas de `CHECK` sur la colonne : la liste des langages vit dans le domaine et -/// bouge d'une version à l'autre — la figer imposerait une migration par ajout. -const MIGRATION_3: &str = r" -BEGIN; - -CREATE INDEX notes_language ON notes (language); - -PRAGMA user_version = 3; +const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -COMMIT; -"; +/// Nombre de migrations qu'a connues l'ancien versionnement par +/// `PRAGMA user_version` : sa valeur maximale livrée était 3. Voir +/// [`adopt_legacy_history`] — cette constante ne bouge plus, une migration +/// ajoutée aujourd'hui n'a jamais existé sous l'ancien schéma. +const LEGACY_MIGRATION_COUNT: usize = 3; /// Les commandes convertissent ces variantes en `AppError` : la variante devient /// un **code** que le front traduit, et le `Display` ci-dessous n'est plus que @@ -134,9 +62,12 @@ pub enum StorageError { SpaceNotFound(String), /// Nom déjà pris (comparaison insensible à la casse). DuplicateSpaceName(String), - /// Base écrite par une version plus récente de l'application. - SchemaTooRecent(i32), - Sqlite(rusqlite::Error), + /// Base portant une migration que ce binaire ne connaît pas : elle a été + /// écrite par une version plus récente de l'application. + SchemaTooRecent(String), + /// Ouverture ou migration impossible — panne d'avant le premier `SELECT`. + Migration(String), + Sqlite(diesel::result::Error), } impl fmt::Display for StorageError { @@ -149,8 +80,9 @@ impl fmt::Display for StorageError { } Self::SchemaTooRecent(version) => write!( f, - "Base de données en version {version}, plus récente que cette version de DevBox (schéma {SCHEMA_VERSION})", + "Base de données portant la migration « {version} », inconnue de cette version de DevBox", ), + Self::Migration(detail) => write!(f, "Migration impossible : {detail}"), Self::Sqlite(error) => write!(f, "Erreur de stockage : {error}"), } } @@ -158,65 +90,180 @@ impl fmt::Display for StorageError { impl std::error::Error for StorageError {} -impl From for StorageError { - fn from(error: rusqlite::Error) -> Self { +/// Requise par `Connection::transaction`, qui exige de savoir absorber l'erreur +/// de Diesel dans celle de l'appelant. +impl From for StorageError { + fn from(error: diesel::result::Error) -> Self { Self::Sqlite(error) } } /// Ouvre la base (en la créant au besoin), la configure, migre. -pub fn open(path: &Path) -> Result { - let connection = Connection::open(path)?; - configure(&connection)?; - migrate(&connection)?; +pub fn open(path: &Path) -> Result { + let mut connection = SqliteConnection::establish(&path.to_string_lossy()) + .map_err(|error| StorageError::Migration(error.to_string()))?; + configure(&mut connection)?; + migrate(&mut connection)?; Ok(connection) } /// Base éphémère, pour les tests. #[cfg(test)] -pub fn open_in_memory() -> Result { - let connection = Connection::open_in_memory()?; - configure(&connection)?; - migrate(&connection)?; +pub fn open_in_memory() -> Result { + let mut connection = SqliteConnection::establish(":memory:") + .map_err(|error| StorageError::Migration(error.to_string()))?; + configure(&mut connection)?; + migrate(&mut connection)?; Ok(connection) } -fn configure(connection: &Connection) -> Result<(), StorageError> { +fn configure(connection: &mut SqliteConnection) -> Result<(), StorageError> { // ⚠️ `foreign_keys` se règle **par connexion** et est désactivé par défaut : // sans lui les `ON DELETE CASCADE` sont inertes et les tags d'une note // supprimée resteraient orphelins. WAL : un lecteur ne bloque plus un écrivain. - connection.execute_batch( + connection.batch_execute( "PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;", )?; Ok(()) } -fn migrate(connection: &Connection) -> Result<(), StorageError> { - let version: i32 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; +/// Versions des migrations embarquées, dans l'ordre d'application. +fn embedded_versions() -> Result, StorageError> { + let mut versions = MigrationSource::::migrations(&MIGRATIONS) + .map_err(|error| StorageError::Migration(error.to_string()))? + .iter() + .map(|migration| migration.name().version().to_string()) + .collect::>(); + versions.sort(); - // Refuser franchement vaut mieux que lire de travers et écraser des données. - if version > SCHEMA_VERSION { - return Err(StorageError::SchemaTooRecent(version)); - } + Ok(versions) +} - // Une base neuve les traverse toutes, une base existante reprend à la sienne. - if version < 1 { - connection.execute_batch(MIGRATION_1)?; - } - if version < 2 { - connection.execute_batch(MIGRATION_2)?; +#[derive(QueryableByName)] +struct UserVersion { + #[diesel(sql_type = Integer)] + user_version: i32, +} + +/// Fait adopter par Diesel l'historique qu'écrivait l'ancien `PRAGMA user_version`. +/// +/// Sans elle, une base déjà installée aurait un `__diesel_schema_migrations` vide +/// et rejouerait la migration initiale sur des tables existantes — échec au +/// lancement. Les `n` premières migrations sont donc marquées comme appliquées +/// sans être exécutées. +/// +/// Le pragma est ensuite remis à zéro : deux sources de vérité sur l'état du +/// schéma finiraient par diverger. Un binaire antérieur à Diesel rouvrant cette +/// base tenterait alors de rejouer la migration initiale et échouerait au +/// lancement — bruyamment, plutôt que d'écrire dans un schéma qu'il croit à jour. +fn adopt_legacy_history( + connection: &mut SqliteConnection, + embedded: &[String], +) -> Result<(), StorageError> { + let legacy: i32 = diesel::sql_query("PRAGMA user_version") + .get_result::(connection)? + .user_version; + + // Zéro : base neuve, ou passée par ici lors d'une ouverture précédente. + if legacy <= 0 { + return Ok(()); } - if version < 3 { - connection.execute_batch(MIGRATION_3)?; + + // Crée `__diesel_schema_migrations` si elle manque — l'insertion suit. + connection + .applied_migrations() + .map_err(|error| StorageError::Migration(error.to_string()))?; + + let adopted = (legacy as usize) + .min(LEGACY_MIGRATION_COUNT) + .min(embedded.len()); + + connection.transaction(|connection| { + for version in &embedded[..adopted] { + diesel::sql_query( + "INSERT OR IGNORE INTO __diesel_schema_migrations (version) VALUES (?)", + ) + .bind::(version) + .execute(connection)?; + } + diesel::sql_query("PRAGMA user_version = 0").execute(connection)?; + + Ok::<_, StorageError>(()) + }) +} + +fn migrate(connection: &mut SqliteConnection) -> Result<(), StorageError> { + let embedded = embedded_versions()?; + + adopt_legacy_history(connection, &embedded)?; + + // Refuser franchement vaut mieux que lire de travers et écraser des données : + // une migration appliquée qu'on ne connaît pas signale une base écrite par + // une version plus récente. + let applied = connection + .applied_migrations() + .map_err(|error| StorageError::Migration(error.to_string()))?; + if let Some(unknown) = applied + .iter() + .map(ToString::to_string) + .find(|version| !embedded.contains(version)) + { + return Err(StorageError::SchemaTooRecent(unknown)); } + connection + .run_pending_migrations(MIGRATIONS) + .map_err(|error| StorageError::Migration(error.to_string()))?; + Ok(()) } #[cfg(test)] mod tests { use super::*; + use diesel::sql_types::BigInt; + + /// Le SQL de la migration initiale tel qu'il a été livré. Rejoué à la main, + /// il fabrique une base « héritée » : schéma en place, `user_version` posé, + /// aucune trace côté Diesel. + const LEGACY_SCHEMA: &str = include_str!("../../migrations/2026-07-25-000001_initial/up.sql"); + const LEGACY_FOLD_TAG_CASE: &str = + include_str!("../../migrations/2026-07-25-000002_fold_tag_case/up.sql"); + + #[derive(QueryableByName)] + struct Count { + #[diesel(sql_type = BigInt)] + count: i64, + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + diesel::sql_query(query) + .get_result::(connection) + .unwrap() + .count + } + + fn user_version(connection: &mut SqliteConnection) -> i32 { + diesel::sql_query("PRAGMA user_version") + .get_result::(connection) + .unwrap() + .user_version + } + + /// Base au schéma d'origine, versionnée comme l'ancien code le faisait. + fn legacy_database(sql: &[&str], version: i32) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + configure(&mut connection).unwrap(); + for statements in sql { + connection.batch_execute(statements).unwrap(); + } + connection + .batch_execute(&format!("PRAGMA user_version = {version}")) + .unwrap(); + + connection + } #[test] fn opening_twice_is_idempotent() { @@ -225,25 +272,28 @@ mod tests { let path = directory.join(DB_FILE_NAME); open(&path).unwrap(); - // A second open must find the schema already at the expected version and - // not attempt to re-create the tables. - let connection = open(&path).unwrap(); + // A second open must find every migration already applied and not + // attempt to re-create the tables. + let mut connection = open(&path).unwrap(); - let version: i32 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); std::fs::remove_dir_all(&directory).ok(); } + #[test] + fn a_fresh_database_applies_every_embedded_migration() { + let mut connection = open_in_memory().unwrap(); + + let applied = connection.applied_migrations().unwrap(); + assert_eq!(applied.len(), embedded_versions().unwrap().len()); + } + #[test] fn a_v1_database_upgrades_and_folds_tag_case() { - let connection = Connection::open_in_memory().unwrap(); - configure(&connection).unwrap(); - connection.execute_batch(MIGRATION_1).unwrap(); - connection - .execute_batch( + let mut connection = legacy_database( + &[ + LEGACY_SCHEMA, "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); INSERT INTO notes VALUES ('n-1', 's-1', 'A', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', @@ -251,90 +301,118 @@ mod tests { ('n-2', 's-1', 'B', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', '2026-07-25T09:00:00.000Z', 'permanent', NULL); INSERT INTO note_tags VALUES ('n-1', 'Urgent'), ('n-2', 'urgent');", - ) - .unwrap(); + ], + 1, + ); - migrate(&connection).unwrap(); + // Passing at all is half the assertion: replaying the initial migration + // on these tables would fail on `CREATE TABLE spaces`. + migrate(&mut connection).unwrap(); - let version: i32 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); // Both rows survive: the collation folds the facet, it does not drop data. - let rows: i64 = connection - .query_row("SELECT COUNT(*) FROM note_tags", [], |row| row.get(0)) - .unwrap(); - assert_eq!(rows, 2); + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM note_tags"), + 2 + ); // But the rail now sees one tag where it used to see two. - let facets: i64 = connection - .query_row( - "SELECT COUNT(*) FROM (SELECT DISTINCT tag FROM note_tags)", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(facets, 1); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM (SELECT DISTINCT tag FROM note_tags)" + ), + 1 + ); } #[test] fn a_v2_database_gains_the_language_index_without_touching_its_notes() { - let connection = Connection::open_in_memory().unwrap(); - configure(&connection).unwrap(); - connection.execute_batch(MIGRATION_1).unwrap(); - connection.execute_batch(MIGRATION_2).unwrap(); - connection - .execute_batch( + let mut connection = legacy_database( + &[ + LEGACY_SCHEMA, + LEGACY_FOLD_TAG_CASE, "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); INSERT INTO notes VALUES ('n-1', 's-1', 'A', 'json', '', '', 0, '2026-07-25T09:00:00.000Z', '2026-07-25T09:00:00.000Z', 'permanent', NULL);", - ) - .unwrap(); + ], + 2, + ); + + migrate(&mut connection).unwrap(); + + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM sqlite_master \ + WHERE type = 'index' AND name = 'notes_language'" + ), + 1 + ); - migrate(&connection).unwrap(); + // The migration is an index, not a rewrite: the note is untouched. + assert_eq!( + schema::notes::table + .select(schema::notes::language) + .first::(&mut connection) + .unwrap(), + "json" + ); + } - let version: i32 = connection - .query_row("PRAGMA user_version", [], |row| row.get(0)) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION); - - let indexes: i64 = connection - .query_row( - "SELECT COUNT(*) FROM sqlite_master \ - WHERE type = 'index' AND name = 'notes_language'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(indexes, 1); + #[test] + fn adopting_a_legacy_history_clears_the_pragma_it_replaces() { + let mut connection = legacy_database(&[LEGACY_SCHEMA], 1); - // The migration is an index, not a rewrite: the note is untouched. - let language: String = connection - .query_row("SELECT language FROM notes WHERE id = 'n-1'", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(language, "json"); + migrate(&mut connection).unwrap(); + + // Two sources of truth on the schema state would drift apart; the + // migrations table is now the only one. + assert_eq!(user_version(&mut connection), 0); + } + + #[test] + fn a_migration_this_binary_does_not_know_is_refused() { + let mut connection = open_in_memory().unwrap(); + diesel::sql_query( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('2099-01-01-000000')", + ) + .execute(&mut connection) + .unwrap(); + + let error = migrate(&mut connection).unwrap_err(); + + // Reading a newer schema with older code would silently write rows the + // newer version cannot make sense of. + assert!(matches!(error, StorageError::SchemaTooRecent(_))); } #[test] fn a_fresh_database_is_empty() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - assert!(notes::list(&connection).unwrap().is_empty()); - assert!(spaces::list(&connection).unwrap().is_empty()); + assert!(notes::list(&mut connection).unwrap().is_empty()); + assert!(spaces::list(&mut connection).unwrap().is_empty()); } #[test] fn foreign_keys_are_enforced() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - let enabled: bool = connection - .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) - .unwrap(); + #[derive(QueryableByName)] + struct ForeignKeys { + #[diesel(sql_type = Integer)] + foreign_keys: i32, + } + + let enabled = diesel::sql_query("PRAGMA foreign_keys") + .get_result::(&mut connection) + .unwrap() + .foreign_keys; - assert!(enabled); + assert_eq!(enabled, 1); } } diff --git a/src-tauri/src/storage/notes.rs b/src-tauri/src/storage/notes.rs index 945abeb..5d91469 100644 --- a/src-tauri/src/storage/notes.rs +++ b/src-tauri/src/storage/notes.rs @@ -10,79 +10,106 @@ use std::collections::HashMap; -use rusqlite::{Connection, Row, ToSql}; +use diesel::prelude::*; use uuid::Uuid; +use super::schema::{note_tags, notes}; use super::{StorageError, spaces}; use crate::domain::note::{Note, NoteDraft, NoteLifecycle, NotePatch}; use crate::domain::view::{Facets, NoteFilter, NotesQuery}; use crate::domain::{detect, rules}; -const NOTE_COLUMNS: &str = "id, space_id, title, language, content, source, pinned, \ - created_at, updated_at, lifecycle_kind, lifecycle_expires_at"; - -/// Note **sans ses tags** : ils vivent dans `note_tags` et sont rattachés -/// ensuite, en une requête pour toute la liste. -fn row_to_note(row: &Row<'_>) -> rusqlite::Result { - let lifecycle_kind: String = row.get("lifecycle_kind")?; - let expires_at: Option = row.get("lifecycle_expires_at")?; +/// Forme tabulaire d'une note : le `lifecycle` du domaine y est éclaté en deux +/// colonnes, et les tags en sont absents — ils vivent dans `note_tags` et sont +/// rattachés ensuite, en une requête pour toute la liste. +#[derive(Queryable, Selectable, Insertable)] +#[diesel(table_name = notes)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +struct NoteRow { + id: String, + space_id: String, + title: String, + language: String, + content: String, + source: String, + pinned: bool, + created_at: String, + updated_at: String, + lifecycle_kind: String, + lifecycle_expires_at: Option, +} - // Le `CHECK` du schéma rend `("expires", None)` inatteignable. - let lifecycle = match (lifecycle_kind.as_str(), expires_at) { - ("expires", Some(at)) => NoteLifecycle::Expires { at }, - _ => NoteLifecycle::Permanent, - }; +impl From for Note { + fn from(row: NoteRow) -> Self { + // Le `CHECK` du schéma rend `("expires", None)` inatteignable. + let lifecycle = match (row.lifecycle_kind.as_str(), row.lifecycle_expires_at) { + ("expires", Some(at)) => NoteLifecycle::Expires { at }, + _ => NoteLifecycle::Permanent, + }; - Ok(Note { - id: row.get("id")?, - space_id: row.get("space_id")?, - title: row.get("title")?, - language: row.get("language")?, - content: row.get("content")?, - source: row.get("source")?, - tags: Vec::new(), - pinned: row.get("pinned")?, - created_at: row.get("created_at")?, - updated_at: row.get("updated_at")?, - lifecycle, - }) + Self { + id: row.id, + space_id: row.space_id, + title: row.title, + language: row.language, + content: row.content, + source: row.source, + tags: Vec::new(), + pinned: row.pinned, + created_at: row.created_at, + updated_at: row.updated_at, + lifecycle, + } + } } -fn lifecycle_columns(lifecycle: &NoteLifecycle) -> (&'static str, Option<&str>) { - match lifecycle { - NoteLifecycle::Permanent => ("permanent", None), - NoteLifecycle::Expires { at } => ("expires", Some(at.as_str())), +impl From<&Note> for NoteRow { + fn from(note: &Note) -> Self { + let (lifecycle_kind, lifecycle_expires_at) = match ¬e.lifecycle { + NoteLifecycle::Permanent => ("permanent", None), + NoteLifecycle::Expires { at } => ("expires", Some(at.clone())), + }; + + Self { + id: note.id.clone(), + space_id: note.space_id.clone(), + title: note.title.clone(), + language: note.language.clone(), + content: note.content.clone(), + source: note.source.clone(), + pinned: note.pinned, + created_at: note.created_at.clone(), + updated_at: note.updated_at.clone(), + lifecycle_kind: lifecycle_kind.to_string(), + lifecycle_expires_at, + } } } /// Tags de toutes les notes en une requête ; une par note coûterait cher dès /// quelques centaines. -fn all_tags(connection: &Connection) -> Result>, StorageError> { - let mut statement = connection.prepare("SELECT note_id, tag FROM note_tags ORDER BY tag")?; - let mut grouped: HashMap> = HashMap::new(); +fn all_tags( + connection: &mut SqliteConnection, +) -> Result>, StorageError> { + let rows = note_tags::table + .select((note_tags::note_id, note_tags::tag)) + .order(note_tags::tag.asc()) + .load::<(String, String)>(connection)?; - let rows = statement.query_map([], |row| { - Ok(( - row.get::<_, String>("note_id")?, - row.get::<_, String>("tag")?, - )) - })?; - for row in rows { - let (note_id, tag) = row?; + let mut grouped: HashMap> = HashMap::new(); + for (note_id, tag) in rows { grouped.entry(note_id).or_default().push(tag); } Ok(grouped) } -fn tags_of(connection: &Connection, note_id: &str) -> Result, StorageError> { - let mut statement = - connection.prepare("SELECT tag FROM note_tags WHERE note_id = ?1 ORDER BY tag")?; - let tags = statement - .query_map([note_id], |row| row.get(0))? - .collect::>>()?; - - Ok(tags) +fn tags_of(connection: &mut SqliteConnection, note_id: &str) -> Result, StorageError> { + Ok(note_tags::table + .filter(note_tags::note_id.eq(note_id)) + .select(note_tags::tag) + .order(note_tags::tag.asc()) + .load::(connection)?) } /// Remplace intégralement les tags, uniquement quand le patch en porte. @@ -92,18 +119,22 @@ fn tags_of(connection: &Connection, note_id: &str) -> Result, Storag /// ne correspond pas à la base. Le tri final aligne l'écriture sur la lecture, /// sans quoi les tags se réordonneraient au rechargement suivant. fn replace_tags( - connection: &Connection, + connection: &mut SqliteConnection, note_id: &str, requested: &[String], ) -> Result, StorageError> { let mut normalized = rules::normalize_tags(requested); - connection.execute("DELETE FROM note_tags WHERE note_id = ?1", [note_id])?; + diesel::delete(note_tags::table.filter(note_tags::note_id.eq(note_id))).execute(connection)?; - let mut statement = - connection.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag) VALUES (?1, ?2)")?; - for tag in &normalized { - statement.execute((note_id, tag))?; + if !normalized.is_empty() { + let rows: Vec<_> = normalized + .iter() + .map(|tag| (note_tags::note_id.eq(note_id), note_tags::tag.eq(tag))) + .collect(); + diesel::insert_or_ignore_into(note_tags::table) + .values(rows) + .execute(connection)?; } normalized.sort(); @@ -114,7 +145,7 @@ fn replace_tags( /// **Réservée aux tests** : en production tout passe par [`fetch`] puis /// `domain::view::build`. #[cfg(test)] -pub fn list(connection: &Connection) -> Result, StorageError> { +pub fn list(connection: &mut SqliteConnection) -> Result, StorageError> { fetch( connection, &NotesQuery { @@ -130,85 +161,56 @@ pub fn list(connection: &Connection) -> Result, StorageError> { .map(|(notes, _)| notes) } -/// Lie `values` et rend la liste de placeholders correspondante (`?3, ?4`). -/// Les numéros suivent `params`, qui peut déjà porter d'autres critères. -fn placeholders(values: &[String], params: &mut Vec>) -> String { - values - .iter() - .map(|value| { - params.push(Box::new(value.clone())); - format!("?{}", params.len()) - }) - .collect::>() - .join(", ") -} - -/// Valeurs distinctes d'une colonne, portées à un espace. Sert les deux rails de -/// facettes, qui posent la même question à deux colonnes près. -fn distinct( - connection: &Connection, - unscoped: &str, - scoped: &str, - space_id: Option<&str>, -) -> Result, StorageError> { - let values = match space_id { - Some(id) => { - let mut statement = connection.prepare(scoped)?; - statement - .query_map([id], |row| row.get(0))? - .collect::>>()? - } - None => { - let mut statement = connection.prepare(unscoped)?; - statement - .query_map([], |row| row.get(0))? - .collect::>>()? - } - }; - - Ok(values) -} - /// Facettes proposables par les rails, portées à l'espace et non au filtre /// courant : ne proposer que celles des notes déjà filtrées viderait les rails /// dès la première sélection, rendant impossible d'en choisir une seconde. -fn facets(connection: &Connection, space_id: Option<&str>) -> Result { +/// +/// La jointure sur `notes` est inconditionnelle : toute ligne de `note_tags` +/// pointe une note existante (clé étrangère), elle n'ajoute donc ni ne retire +/// aucun tag quand aucun espace n'est actif. +fn facets( + connection: &mut SqliteConnection, + space_id: Option<&str>, +) -> Result { + let mut tags = note_tags::table + .inner_join(notes::table) + .select(note_tags::tag) + .distinct() + .order(note_tags::tag.asc()) + .into_boxed(); + let mut languages = notes::table + .select(notes::language) + .distinct() + .order(notes::language.asc()) + .into_boxed(); + + if let Some(id) = space_id { + tags = tags.filter(notes::space_id.eq(id.to_string())); + languages = languages.filter(notes::space_id.eq(id.to_string())); + } + Ok(Facets { - tags: distinct( - connection, - "SELECT DISTINCT tag FROM note_tags ORDER BY tag", - "SELECT DISTINCT tag FROM note_tags \ - JOIN notes ON notes.id = note_tags.note_id \ - WHERE notes.space_id = ?1 ORDER BY tag", - space_id, - )?, - languages: distinct( - connection, - "SELECT DISTINCT language FROM notes ORDER BY language", - "SELECT DISTINCT language FROM notes WHERE space_id = ?1 ORDER BY language", - space_id, - )?, + tags: tags.load::(connection)?, + languages: languages.load::(connection)?, }) } /// Notes retenues par les critères **grossiers**, et facettes des rails. /// `domain::view::build` prend le relais pour la recherche texte et les sections. pub fn fetch( - connection: &Connection, + connection: &mut SqliteConnection, request: &NotesQuery, ) -> Result<(Vec, Facets), StorageError> { - let mut conditions: Vec = Vec::new(); - let mut params: Vec> = Vec::new(); + let mut query = notes::table.select(NoteRow::as_select()).into_boxed(); if let Some(space_id) = &request.space_id { - params.push(Box::new(space_id.clone())); - conditions.push(format!("space_id = ?{}", params.len())); + query = query.filter(notes::space_id.eq(space_id.clone())); } match request.filter { NoteFilter::All => {} - NoteFilter::Pinned => conditions.push("pinned = 1".to_string()), - NoteFilter::Untriaged => conditions.push("lifecycle_kind = 'expires'".to_string()), + NoteFilter::Pinned => query = query.filter(notes::pinned.eq(true)), + NoteFilter::Untriaged => query = query.filter(notes::lifecycle_kind.eq("expires")), } // Pas de normalisation, contrairement aux tags : un langage est choisi dans @@ -216,38 +218,34 @@ pub fn fetch( // résultat attendu. if !request.languages.is_empty() { // Union, comme les tags : sélectionner JSON puis YAML montre les deux. - let bound = placeholders(&request.languages, &mut params); - conditions.push(format!("language IN ({bound})")); + query = query.filter(notes::language.eq_any(request.languages.clone())); } // Même normalisation qu'à l'écriture, sinon un `#urgent` saisi au clavier ne - // retrouverait pas le tag `urgent` stocké. + // retrouverait pas le tag `urgent` stocké. La comparaison qui suit se fait + // dans la collation de `note_tags.tag`, donc `NOCASE`. let selected_tags = rules::normalize_tags(&request.tags); if !selected_tags.is_empty() { // « au moins un tag », pas « tous » : comportement d'un rail de facettes. - let bound = placeholders(&selected_tags, &mut params); - conditions.push(format!( - "EXISTS (SELECT 1 FROM note_tags WHERE note_id = notes.id AND tag IN ({bound}))" - )); + query = query.filter( + notes::id.eq_any( + note_tags::table + .select(note_tags::note_id) + .filter(note_tags::tag.eq_any(selected_tags)), + ), + ); } - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!(" WHERE {}", conditions.join(" AND ")) - }; - // Tri décidé ici une fois pour toutes ; le front conserve l'ordre reçu. // Sur `updated_at` alors que les sections regroupent sur `created_at` : // la section dit quand la note est née, l'ordre interne laquelle a été // touchée en dernier. - let mut statement = connection.prepare(&format!( - "SELECT {NOTE_COLUMNS} FROM notes{where_clause} ORDER BY updated_at DESC, id" - ))?; - let bound: Vec<&dyn ToSql> = params.iter().map(|param| param.as_ref()).collect(); - let mut notes = statement - .query_map(bound.as_slice(), row_to_note)? - .collect::>>()?; + let mut notes = query + .order((notes::updated_at.desc(), notes::id.asc())) + .load::(connection)? + .into_iter() + .map(Note::from) + .collect::>(); // Rattachés avant de rendre la main : la recherche du domaine porte dessus. let mut grouped = all_tags(connection)?; @@ -258,75 +256,56 @@ pub fn fetch( Ok((notes, facets(connection, request.space_id.as_deref())?)) } -fn find(connection: &Connection, id: &str) -> Result, StorageError> { - let mut statement = - connection.prepare(&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"))?; - let mut rows = statement.query_map([id], row_to_note)?; - - let Some(note) = rows.next().transpose()? else { +fn find(connection: &mut SqliteConnection, id: &str) -> Result, StorageError> { + let Some(row) = notes::table + .find(id) + .select(NoteRow::as_select()) + .first::(connection) + .optional()? + else { return Ok(None); }; - drop(rows); Ok(Some(Note { tags: tags_of(connection, id)?, - ..note + ..Note::from(row) })) } /// Renvoie la version persistée — identifiant définitif et horodatages compris. /// Le front adopte cette valeur telle quelle. pub fn create( - connection: &mut Connection, + connection: &mut SqliteConnection, draft: &NoteDraft, now: &str, ) -> Result { - let transaction = connection.transaction()?; + connection.transaction(|connection| { + if !spaces::exists(connection, &draft.space_id)? { + return Err(StorageError::SpaceNotFound(draft.space_id.clone())); + } - if !spaces::exists(&transaction, &draft.space_id)? { - return Err(StorageError::SpaceNotFound(draft.space_id.clone())); - } + let mut note = Note { + id: Uuid::new_v4().to_string(), + space_id: draft.space_id.clone(), + title: draft.title.clone(), + language: draft.language.clone(), + content: draft.content.clone(), + source: draft.source.clone(), + tags: draft.tags.clone(), + pinned: draft.pinned, + created_at: now.to_string(), + updated_at: now.to_string(), + lifecycle: draft.lifecycle.clone(), + }; - let mut note = Note { - id: Uuid::new_v4().to_string(), - space_id: draft.space_id.clone(), - title: draft.title.clone(), - language: draft.language.clone(), - content: draft.content.clone(), - source: draft.source.clone(), - tags: draft.tags.clone(), - pinned: draft.pinned, - created_at: now.to_string(), - updated_at: now.to_string(), - lifecycle: draft.lifecycle.clone(), - }; + diesel::insert_into(notes::table) + .values(NoteRow::from(¬e)) + .execute(connection)?; + // Les tags écrits sont normalisés, pas ceux du brouillon. + note.tags = replace_tags(connection, ¬e.id, &draft.tags)?; - let (lifecycle_kind, expires_at) = lifecycle_columns(¬e.lifecycle); - transaction.execute( - &format!( - "INSERT INTO notes ({NOTE_COLUMNS}) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" - ), - rusqlite::params![ - ¬e.id, - ¬e.space_id, - ¬e.title, - ¬e.language, - ¬e.content, - ¬e.source, - note.pinned, - ¬e.created_at, - ¬e.updated_at, - lifecycle_kind, - expires_at, - ], - )?; - // Les tags écrits sont normalisés, pas ceux du brouillon. - note.tags = replace_tags(&transaction, ¬e.id, &draft.tags)?; - - transaction.commit()?; - - Ok(note) + Ok(note) + }) } /// Applique **uniquement** les champs renseignés du patch et rafraîchit @@ -335,83 +314,79 @@ pub fn create( /// /// Identifiant inconnu ⇒ `Err` : le front croirait sinon avoir enregistré. pub fn update( - connection: &mut Connection, + connection: &mut SqliteConnection, id: &str, patch: &NotePatch, now: &str, ) -> Result { - let transaction = connection.transaction()?; - - let Some(mut note) = find(&transaction, id)? else { - return Err(StorageError::NoteNotFound(id.to_string())); - }; + connection.transaction(|connection| { + let Some(mut note) = find(connection, id)? else { + return Err(StorageError::NoteNotFound(id.to_string())); + }; - // Décidé sur la note **avant** patch : c'est son état d'origine qui dit si - // elle reçoit là son premier contenu. La règle est dans le domaine, comme - // `normalize_tags` ; ici on ne fait que l'appliquer. - let detected = detect::language_after_patch(¬e, patch); + // Décidé sur la note **avant** patch : c'est son état d'origine qui dit si + // elle reçoit là son premier contenu. La règle est dans le domaine, comme + // `normalize_tags` ; ici on ne fait que l'appliquer. + let detected = detect::language_after_patch(¬e, patch); - if let Some(space_id) = &patch.space_id { - if !spaces::exists(&transaction, space_id)? { - return Err(StorageError::SpaceNotFound(space_id.clone())); + if let Some(space_id) = &patch.space_id { + if !spaces::exists(connection, space_id)? { + return Err(StorageError::SpaceNotFound(space_id.clone())); + } + note.space_id = space_id.clone(); + } + if let Some(title) = &patch.title { + note.title = title.clone(); + } + if let Some(language) = &patch.language { + note.language = language.clone(); + } + if let Some(content) = &patch.content { + note.content = content.clone(); + } + if let Some(language) = detected { + note.language = language; + } + if let Some(source) = &patch.source { + note.source = source.clone(); + } + if let Some(pinned) = patch.pinned { + note.pinned = pinned; + } + if let Some(lifecycle) = &patch.lifecycle { + note.lifecycle = lifecycle.clone(); + } + note.updated_at = now.to_string(); + + // Colonnes énumérées plutôt qu'un `AsChangeset` sur `NoteRow` : celui-ci + // réécrirait aussi `created_at`, que rien ici n'a le droit de bouger. + let row = NoteRow::from(¬e); + diesel::update(notes::table.find(¬e.id)) + .set(( + notes::space_id.eq(&row.space_id), + notes::title.eq(&row.title), + notes::language.eq(&row.language), + notes::content.eq(&row.content), + notes::source.eq(&row.source), + notes::pinned.eq(row.pinned), + notes::updated_at.eq(&row.updated_at), + notes::lifecycle_kind.eq(&row.lifecycle_kind), + notes::lifecycle_expires_at.eq(&row.lifecycle_expires_at), + )) + .execute(connection)?; + + if let Some(tags) = &patch.tags { + note.tags = replace_tags(connection, ¬e.id, tags)?; } - note.space_id = space_id.clone(); - } - if let Some(title) = &patch.title { - note.title = title.clone(); - } - if let Some(language) = &patch.language { - note.language = language.clone(); - } - if let Some(content) = &patch.content { - note.content = content.clone(); - } - if let Some(language) = detected { - note.language = language; - } - if let Some(source) = &patch.source { - note.source = source.clone(); - } - if let Some(pinned) = patch.pinned { - note.pinned = pinned; - } - if let Some(lifecycle) = &patch.lifecycle { - note.lifecycle = lifecycle.clone(); - } - note.updated_at = now.to_string(); - - let (lifecycle_kind, expires_at) = lifecycle_columns(¬e.lifecycle); - transaction.execute( - "UPDATE notes SET space_id = ?2, title = ?3, language = ?4, content = ?5, source = ?6, \ - pinned = ?7, updated_at = ?8, lifecycle_kind = ?9, lifecycle_expires_at = ?10 \ - WHERE id = ?1", - rusqlite::params![ - ¬e.id, - ¬e.space_id, - ¬e.title, - ¬e.language, - ¬e.content, - ¬e.source, - note.pinned, - ¬e.updated_at, - lifecycle_kind, - expires_at, - ], - )?; - - if let Some(tags) = &patch.tags { - note.tags = replace_tags(&transaction, ¬e.id, tags)?; - } - - transaction.commit()?; - Ok(note) + Ok(note) + }) } /// Ses tags partent par cascade (d'où le `PRAGMA foreign_keys = ON` de /// `storage::configure`). Identifiant inconnu ⇒ `Err`. -pub fn delete(connection: &Connection, id: &str) -> Result<(), StorageError> { - let deleted = connection.execute("DELETE FROM notes WHERE id = ?1", [id])?; +pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), StorageError> { + let deleted = diesel::delete(notes::table.find(id)).execute(connection)?; if deleted == 0 { return Err(StorageError::NoteNotFound(id.to_string())); @@ -426,6 +401,7 @@ mod tests { use crate::domain::view; use crate::domain::view::NotesView; use crate::storage::open_in_memory; + use crate::storage::schema::spaces as spaces_table; const T0: &str = "2026-07-25T09:00:00.000Z"; const T1: &str = "2026-07-25T10:00:00.000Z"; @@ -433,12 +409,15 @@ mod tests { /// Chemin de lecture complet — SQL puis règles — tel que `query_notes` /// l'assemble. Les règles ont leurs propres tests dans `domain/` ; ici on /// vérifie qu'elles s'appliquent bien à ce que la base a réellement rendu. - fn query(connection: &Connection, request: &NotesQuery) -> Result { + fn query( + connection: &mut SqliteConnection, + request: &NotesQuery, + ) -> Result { let (notes, facets) = fetch(connection, request)?; Ok(view::build(notes, facets, request).expect("les tests fournissent un instant valide")) } - fn space(connection: &Connection, name: &str) -> String { + fn space(connection: &mut SqliteConnection, name: &str) -> String { spaces::create(connection, name).unwrap().id } @@ -458,10 +437,10 @@ mod tests { #[test] fn a_created_note_is_read_back_whole() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - let listed = list(&connection).unwrap(); + let listed = list(&mut connection).unwrap(); assert_eq!(listed.len(), 1); let note = &listed[0]; @@ -478,7 +457,7 @@ mod tests { #[test] fn creation_stamps_both_dates_identically() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); @@ -489,7 +468,7 @@ mod tests { #[test] fn an_expiring_lifecycle_survives_a_round_trip() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let expiring = NoteDraft { lifecycle: NoteLifecycle::Expires { at: "2026-08-01T00:00:00.000Z".to_string(), @@ -499,13 +478,39 @@ mod tests { create(&mut connection, &expiring, T0).unwrap(); - let listed = list(&connection).unwrap(); + let listed = list(&mut connection).unwrap(); assert!(matches!( &listed[0].lifecycle, NoteLifecycle::Expires { at } if at == "2026-08-01T00:00:00.000Z" )); } + #[test] + fn dropping_an_expiry_clears_the_stored_date() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let expiring = NoteDraft { + lifecycle: NoteLifecycle::Expires { + at: "2026-08-01T00:00:00.000Z".to_string(), + }, + ..draft(&space_id) + }; + let created = create(&mut connection, &expiring, T0).unwrap(); + + let patch = NotePatch { + lifecycle: Some(NoteLifecycle::Permanent), + ..NotePatch::default() + }; + update(&mut connection, &created.id, &patch, T1).unwrap(); + + // The schema's CHECK ties the two columns together: leaving the date + // behind would make the write fail outright. + assert!(matches!( + list(&mut connection).unwrap()[0].lifecycle, + NoteLifecycle::Permanent + )); + } + #[test] fn creating_in_an_unknown_space_is_refused() { let mut connection = open_in_memory().unwrap(); @@ -513,18 +518,18 @@ mod tests { let error = create(&mut connection, &draft("inconnu"), T0).unwrap_err(); assert!(matches!(error, StorageError::SpaceNotFound(_))); - assert!(list(&connection).unwrap().is_empty()); + assert!(list(&mut connection).unwrap().is_empty()); } #[test] fn notes_are_listed_most_recently_updated_first() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let older = create(&mut connection, &draft(&space_id), T0).unwrap(); let newer = create(&mut connection, &draft(&space_id), T1).unwrap(); - let ids: Vec = list(&connection) + let ids: Vec = list(&mut connection) .unwrap() .into_iter() .map(|n| n.id) @@ -536,7 +541,7 @@ mod tests { #[test] fn an_absent_patch_field_leaves_the_stored_value_untouched() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); let patch = NotePatch { @@ -556,7 +561,7 @@ mod tests { #[test] fn updating_refreshes_updated_at_but_not_created_at() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); let updated = update(&mut connection, &created.id, &NotePatch::default(), T1).unwrap(); @@ -571,7 +576,7 @@ mod tests { // rule is tested in `domain::detect`; here we check it actually reaches // the stored row. let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let empty = NoteDraft { language: "txt".to_string(), content: String::new(), @@ -586,13 +591,13 @@ mod tests { let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); assert_eq!(updated.language, "ts"); - assert_eq!(list(&connection).unwrap()[0].language, "ts"); + assert_eq!(list(&mut connection).unwrap()[0].language, "ts"); } #[test] fn a_later_edit_does_not_move_the_language_again() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let empty = NoteDraft { language: "txt".to_string(), content: String::new(), @@ -619,7 +624,7 @@ mod tests { #[test] fn patching_tags_replaces_the_whole_set() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); let patch = NotePatch { @@ -628,13 +633,13 @@ mod tests { }; update(&mut connection, &created.id, &patch, T1).unwrap(); - assert_eq!(list(&connection).unwrap()[0].tags, ["sql"]); + assert_eq!(list(&mut connection).unwrap()[0].tags, ["sql"]); } #[test] fn patching_tags_to_an_empty_list_clears_them() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); let patch = NotePatch { @@ -644,14 +649,14 @@ mod tests { let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); assert!(updated.tags.is_empty()); - assert!(list(&connection).unwrap()[0].tags.is_empty()); + assert!(list(&mut connection).unwrap()[0].tags.is_empty()); } #[test] fn a_note_can_be_moved_to_another_space() { let mut connection = open_in_memory().unwrap(); - let origin = space(&connection, "Perso"); - let destination = space(&connection, "Boulot"); + let origin = space(&mut connection, "Perso"); + let destination = space(&mut connection, "Boulot"); let created = create(&mut connection, &draft(&origin), T0).unwrap(); let patch = NotePatch { @@ -666,7 +671,7 @@ mod tests { #[test] fn moving_a_note_to_an_unknown_space_is_refused_and_changes_nothing() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); let patch = NotePatch { @@ -677,7 +682,7 @@ mod tests { let error = update(&mut connection, &created.id, &patch, T1).unwrap_err(); assert!(matches!(error, StorageError::SpaceNotFound(_))); - let note = &list(&connection).unwrap()[0]; + let note = &list(&mut connection).unwrap()[0]; assert_eq!(note.space_id, space_id); assert_eq!(note.title, "Titre"); } @@ -694,23 +699,24 @@ mod tests { #[test] fn deleting_removes_the_note_and_its_tags() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - delete(&connection, &created.id).unwrap(); + delete(&mut connection, &created.id).unwrap(); - assert!(list(&connection).unwrap().is_empty()); - let orphan_tags: i64 = connection - .query_row("SELECT COUNT(*) FROM note_tags", [], |row| row.get(0)) + assert!(list(&mut connection).unwrap().is_empty()); + let orphan_tags = note_tags::table + .count() + .get_result::(&mut connection) .unwrap(); assert_eq!(orphan_tags, 0); } #[test] fn deleting_an_unknown_note_reports_an_error() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - let error = delete(&connection, "inconnu").unwrap_err(); + let error = delete(&mut connection, "inconnu").unwrap_err(); assert!(matches!(error, StorageError::NoteNotFound(_))); } @@ -753,10 +759,10 @@ mod tests { #[test] fn a_query_without_criteria_returns_every_note() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &draft(&space_id), T0).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); assert_eq!(view.matched, 1); assert!(!view.is_filtering); @@ -765,13 +771,13 @@ mod tests { #[test] fn the_space_filter_excludes_the_other_spaces() { let mut connection = open_in_memory().unwrap(); - let here = space(&connection, "Perso"); - let elsewhere = space(&connection, "Boulot"); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); let kept = create(&mut connection, &draft(&here), T0).unwrap(); create(&mut connection, &draft(&elsewhere), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { space_id: Some(here), ..all_notes() @@ -785,12 +791,12 @@ mod tests { #[test] fn no_space_means_every_space_rather_than_none() { let mut connection = open_in_memory().unwrap(); - let here = space(&connection, "Perso"); - let elsewhere = space(&connection, "Boulot"); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); create(&mut connection, &draft(&here), T0).unwrap(); create(&mut connection, &draft(&elsewhere), T0).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); assert_eq!(view.matched, 2); } @@ -798,7 +804,7 @@ mod tests { #[test] fn the_pinned_filter_keeps_only_pinned_notes() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let pinned = create( &mut connection, &NoteDraft { @@ -811,7 +817,7 @@ mod tests { create(&mut connection, &draft(&space_id), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { filter: NoteFilter::Pinned, ..all_notes() @@ -825,7 +831,7 @@ mod tests { #[test] fn the_untriaged_filter_keeps_only_expiring_notes() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let expiring = create( &mut connection, &NoteDraft { @@ -840,7 +846,7 @@ mod tests { create(&mut connection, &draft(&space_id), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { filter: NoteFilter::Untriaged, ..all_notes() @@ -854,11 +860,11 @@ mod tests { #[test] fn a_quick_filter_alone_does_not_switch_to_results_mode() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &draft(&space_id), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { filter: NoteFilter::Pinned, ..all_notes() @@ -874,7 +880,7 @@ mod tests { #[test] fn the_search_matches_the_title_the_content_and_the_tags() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let by_title = create( &mut connection, &NoteDraft { @@ -905,7 +911,7 @@ mod tests { ("urgent", &by_tag), ] { let view = query( - &connection, + &mut connection, &NotesQuery { search: needle.to_string(), ..all_notes() @@ -923,7 +929,7 @@ mod tests { #[test] fn the_search_ignores_case_beyond_ascii() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create( &mut connection, &NoteDraft { @@ -937,7 +943,7 @@ mod tests { // SQLite's LOWER() only folds ASCII, so "É" would never match "é" if the // search were pushed into SQL. This is why it is done in Rust. let view = query( - &connection, + &mut connection, &NotesQuery { search: "étape".to_string(), ..all_notes() @@ -951,11 +957,11 @@ mod tests { #[test] fn a_blank_search_is_not_a_search() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &draft(&space_id), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { search: " ".to_string(), ..all_notes() @@ -970,13 +976,13 @@ mod tests { #[test] fn a_note_matches_when_it_carries_at_least_one_selected_tag() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let one = create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); let two = create(&mut connection, &tagged(&space_id, &["later"]), T0).unwrap(); create(&mut connection, &tagged(&space_id, &["neither"]), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { tags: vec!["urgent".to_string(), "later".to_string()], ..all_notes() @@ -995,11 +1001,11 @@ mod tests { #[test] fn a_selected_tag_is_normalised_like_a_stored_one() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { tags: vec![" #urgent ".to_string()], ..all_notes() @@ -1013,11 +1019,11 @@ mod tests { #[test] fn a_selected_tag_matches_a_stored_one_of_a_different_case() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &tagged(&space_id, &["Urgent"]), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { tags: vec!["urgent".to_string()], ..all_notes() @@ -1033,11 +1039,11 @@ mod tests { #[test] fn the_rail_offers_one_facet_for_tags_differing_only_in_case() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &tagged(&space_id, &["Urgent"]), T0).unwrap(); create(&mut connection, &tagged(&space_id, &["urgent"]), T1).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); // normalize_tags folds case within one note; the collation extends that // to the whole corpus, which is what the rail reads. @@ -1047,8 +1053,8 @@ mod tests { #[test] fn criteria_combine_rather_than_replace_each_other() { let mut connection = open_in_memory().unwrap(); - let here = space(&connection, "Perso"); - let elsewhere = space(&connection, "Boulot"); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); let target = create( &mut connection, @@ -1108,7 +1114,7 @@ mod tests { .unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { space_id: Some(here), search: "deploy".to_string(), @@ -1125,13 +1131,13 @@ mod tests { #[test] fn a_note_matches_when_it_is_written_in_one_of_the_selected_languages() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let json = create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); let yml = create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); create(&mut connection, &written_in(&space_id, "py"), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { languages: vec!["json".to_string(), "yml".to_string()], ..all_notes() @@ -1150,7 +1156,7 @@ mod tests { #[test] fn the_language_filter_combines_with_the_other_criteria() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let target = create( &mut connection, &NoteDraft { @@ -1173,7 +1179,7 @@ mod tests { .unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { search: "deploy".to_string(), languages: vec!["yml".to_string()], @@ -1188,11 +1194,11 @@ mod tests { #[test] fn an_unknown_selected_language_matches_nothing_rather_than_everything() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { languages: vec!["cobol".to_string()], ..all_notes() @@ -1206,12 +1212,12 @@ mod tests { #[test] fn available_languages_are_sorted_and_de_duplicated() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); create(&mut connection, &written_in(&space_id, "json"), T1).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); assert_eq!(view.available_languages, ["json", "yml"]); } @@ -1219,13 +1225,13 @@ mod tests { #[test] fn available_languages_are_scoped_to_the_active_space() { let mut connection = open_in_memory().unwrap(); - let here = space(&connection, "Perso"); - let elsewhere = space(&connection, "Boulot"); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); create(&mut connection, &written_in(&here, "json"), T0).unwrap(); create(&mut connection, &written_in(&elsewhere, "sql"), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { space_id: Some(here), ..all_notes() @@ -1240,12 +1246,12 @@ mod tests { #[test] fn available_languages_ignore_the_current_selection() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { languages: vec!["json".to_string()], ..all_notes() @@ -1262,11 +1268,11 @@ mod tests { #[test] fn available_tags_are_sorted_and_de_duplicated() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &tagged(&space_id, &["zeta", "alpha"]), T0).unwrap(); create(&mut connection, &tagged(&space_id, &["alpha", "beta"]), T0).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); assert_eq!(view.available_tags, ["alpha", "beta", "zeta"]); } @@ -1274,13 +1280,13 @@ mod tests { #[test] fn available_tags_are_scoped_to_the_active_space() { let mut connection = open_in_memory().unwrap(); - let here = space(&connection, "Perso"); - let elsewhere = space(&connection, "Boulot"); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); create(&mut connection, &tagged(&here, &["here-tag"]), T0).unwrap(); create(&mut connection, &tagged(&elsewhere, &["elsewhere-tag"]), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { space_id: Some(here), ..all_notes() @@ -1295,12 +1301,12 @@ mod tests { #[test] fn available_tags_ignore_the_current_search_and_selection() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); create(&mut connection, &tagged(&space_id, &["later"]), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { tags: vec!["urgent".to_string()], ..all_notes() @@ -1317,11 +1323,11 @@ mod tests { #[test] fn a_search_matching_nothing_reports_filtering_with_zero_matches() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &draft(&space_id), T0).unwrap(); let view = query( - &connection, + &mut connection, &NotesQuery { search: "introuvable".to_string(), ..all_notes() @@ -1338,11 +1344,11 @@ mod tests { #[test] fn the_view_orders_notes_most_recently_updated_first() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let older = create(&mut connection, &draft(&space_id), T0).unwrap(); let newer = create(&mut connection, &draft(&space_id), T1).unwrap(); - let view = query(&connection, &all_notes()).unwrap(); + let view = query(&mut connection, &all_notes()).unwrap(); assert_eq!(matched_ids(&view), [newer.id, older.id]); } @@ -1350,7 +1356,7 @@ mod tests { #[test] fn tags_are_normalised_on_write() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create( &mut connection, @@ -1362,33 +1368,33 @@ mod tests { // Padding and leading hashes are stripped, blanks dropped, and the // case-insensitive duplicate collapses onto the first spelling. assert_eq!(created.tags, ["later", "urgent"]); - assert_eq!(list(&connection).unwrap()[0].tags, ["later", "urgent"]); + assert_eq!(list(&mut connection).unwrap()[0].tags, ["later", "urgent"]); } #[test] fn a_normalised_write_returns_what_a_read_would_return() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); let created = create(&mut connection, &tagged(&space_id, &["zeta", "alpha"]), T0).unwrap(); // The front adopts the returned note; a different order here would make // the tags jump around on the next reload. - assert_eq!(created.tags, list(&connection).unwrap()[0].tags); + assert_eq!(created.tags, list(&mut connection).unwrap()[0].tags); } #[test] fn deleting_a_space_takes_its_notes_with_it() { let mut connection = open_in_memory().unwrap(); - let space_id = space(&connection, "Perso"); + let space_id = space(&mut connection, "Perso"); create(&mut connection, &draft(&space_id), T0).unwrap(); - connection - .execute("DELETE FROM spaces WHERE id = ?1", [&space_id]) + diesel::delete(spaces_table::table.find(&space_id)) + .execute(&mut connection) .unwrap(); // No command exposes this yet, but the cascade must already hold: // a note whose space is gone would be invisible and unreachable. - assert!(list(&connection).unwrap().is_empty()); + assert!(list(&mut connection).unwrap().is_empty()); } } diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs new file mode 100644 index 0000000..124a174 --- /dev/null +++ b/src-tauri/src/storage/schema.rs @@ -0,0 +1,48 @@ +//! Table Diesel de chaque table SQLite : le miroir typé du schéma que +//! `migrations/` construit. +//! +//! Écrit à la main plutôt que généré par `diesel print-schema`, qui exigerait +//! une base à jour sur la machine de build et rendrait `cargo check` dépendant +//! d'un fichier hors du dépôt. La contrepartie est de le tenir en phase avec les +//! migrations ; `check_for_backend` sur les structures de ligne et les tests de +//! `storage::` échouent bruyamment si les deux divergent. +//! +//! Ce qui **n'apparaît pas** ici et vit uniquement dans le SQL des migrations : +//! les `CHECK`, les `ON DELETE CASCADE` et la collation `NOCASE` de +//! `note_tags.tag`. Diesel ne les modélise pas — il les subit, ce qui est le bon +//! sens de la dépendance. + +diesel::table! { + spaces (id) { + id -> Text, + name -> Text, + } +} + +diesel::table! { + notes (id) { + id -> Text, + space_id -> Text, + title -> Text, + language -> Text, + content -> Text, + source -> Text, + pinned -> Bool, + created_at -> Text, + updated_at -> Text, + lifecycle_kind -> Text, + lifecycle_expires_at -> Nullable, + } +} + +diesel::table! { + note_tags (note_id, tag) { + note_id -> Text, + tag -> Text, + } +} + +diesel::joinable!(notes -> spaces (space_id)); +diesel::joinable!(note_tags -> notes (note_id)); + +diesel::allow_tables_to_appear_in_same_query!(spaces, notes, note_tags); diff --git a/src-tauri/src/storage/spaces.rs b/src-tauri/src/storage/spaces.rs index 20084f8..b058cc0 100644 --- a/src-tauri/src/storage/spaces.rs +++ b/src-tauri/src/storage/spaces.rs @@ -1,42 +1,48 @@ //! Lecture et écriture des espaces. //! -//! Fonctions ordinaires prenant une `&Connection` : les `#[tauri::command]` de -//! `commands/spaces.rs` ne font que les appeler. Voir `storage/mod.rs`. +//! Fonctions ordinaires prenant une `&mut SqliteConnection` : les +//! `#[tauri::command]` de `commands/spaces.rs` ne font que les appeler. Voir +//! `storage/mod.rs`. +//! +//! Pas de structure de ligne ici, contrairement aux notes : `Space` a deux +//! champs et traverse tel quel. Une `SpaceRow` identique au type du domaine +//! serait un mappeur d'identité, écrit pour la symétrie et pour rien d'autre. -use rusqlite::{Connection, Row}; -use uuid::Uuid; +use diesel::dsl::sql; +use diesel::prelude::*; +use diesel::sql_types::{Bool, Text}; use super::StorageError; +use super::schema::{notes, spaces}; use crate::domain::space::Space; - -fn row_to_space(row: &Row<'_>) -> rusqlite::Result { - Ok(Space { - id: row.get("id")?, - name: row.get("name")?, - }) -} +use uuid::Uuid; /// Tous les espaces, triés par nom. Une liste vide est valide : c'est l'état du /// premier lancement. Aucun espace « Tous » n'est fabriqué ici. -pub fn list(connection: &Connection) -> Result, StorageError> { - let mut statement = - connection.prepare("SELECT id, name FROM spaces ORDER BY name COLLATE NOCASE")?; - let spaces = statement - .query_map([], row_to_space)? - .collect::>>()?; - - Ok(spaces) +pub fn list(connection: &mut SqliteConnection) -> Result, StorageError> { + let rows = spaces::table + .select((spaces::id, spaces::name)) + // Fragment brut : Diesel ne modélise pas les collations, et trier en + // BINARY rangerait « perso » après « Veille ». + .order(sql::("name COLLATE NOCASE")) + .load::<(String, String)>(connection)?; + + Ok(rows + .into_iter() + .map(|(id, name)| Space { id, name }) + .collect()) } /// Vérifié avant de ranger une note : la clé étrangère l'attraperait aussi, mais /// avec un message SQLite illisible là où le front affiche l'erreur. -pub fn exists(connection: &Connection, id: &str) -> Result { - let count: i64 = - connection.query_row("SELECT COUNT(*) FROM spaces WHERE id = ?1", [id], |row| { - row.get(0) - })?; - - Ok(count > 0) +pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result { + let found = spaces::table + .find(id) + .select(spaces::id) + .first::(connection) + .optional()?; + + Ok(found.is_some()) } /// Doublon détecté ici plutôt que laissé à l'index unique, pour remonter au @@ -46,24 +52,26 @@ pub fn exists(connection: &Connection, id: &str) -> Result { /// (« perso » → « Perso ») se ferait refuser comme un doublon de lui-même, la /// comparaison étant en `COLLATE NOCASE`. fn ensure_unique_name( - connection: &Connection, + connection: &mut SqliteConnection, name: &str, except_id: Option<&str>, ) -> Result<(), StorageError> { - let taken: i64 = match except_id { - Some(id) => connection.query_row( - "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE AND id <> ?2", - (name, id), - |row| row.get(0), - ), - None => connection.query_row( - "SELECT COUNT(*) FROM spaces WHERE name = ?1 COLLATE NOCASE", - [name], - |row| row.get(0), - ), - }?; - - if taken > 0 { + // `spaces.name` n'est pas déclarée `NOCASE` — seul l'index unique l'est — + // donc la collation doit être posée sur la comparaison, faute de quoi elle + // se ferait en BINARY et laisserait passer « PERSO » à côté de « Perso ». + let mut query = spaces::table + .filter( + sql::("name = ") + .bind::(name.to_string()) + .sql(" COLLATE NOCASE"), + ) + .into_boxed(); + + if let Some(id) = except_id { + query = query.filter(spaces::id.ne(id.to_string())); + } + + if query.count().get_result::(connection)? > 0 { return Err(StorageError::DuplicateSpaceName(name.to_string())); } @@ -73,7 +81,7 @@ fn ensure_unique_name( /// Renvoie la version persistée : le front sélectionne aussitôt l'espace à /// partir de cette valeur. `name` est attendu **déjà validé** (détouré, non /// vide) — cette couche ne tranche que l'unicité. -pub fn create(connection: &Connection, name: &str) -> Result { +pub fn create(connection: &mut SqliteConnection, name: &str) -> Result { ensure_unique_name(connection, name, None)?; let space = Space { @@ -81,23 +89,28 @@ pub fn create(connection: &Connection, name: &str) -> Result Result { +pub fn rename( + connection: &mut SqliteConnection, + id: &str, + name: &str, +) -> Result { if !exists(connection, id)? { return Err(StorageError::SpaceNotFound(id.to_string())); } ensure_unique_name(connection, name, Some(id))?; - connection.execute("UPDATE spaces SET name = ?2 WHERE id = ?1", (id, name))?; + diesel::update(spaces::table.find(id)) + .set(spaces::name.eq(name)) + .execute(connection)?; Ok(Space { id: id.to_string(), @@ -115,25 +128,26 @@ pub fn rename(connection: &Connection, id: &str, name: &str) -> Result Result<(), StorageError> { - let transaction = connection.transaction()?; - - if !exists(&transaction, id)? { - return Err(StorageError::SpaceNotFound(id.to_string())); - } - if !exists(&transaction, target_id)? { - return Err(StorageError::SpaceNotFound(target_id.to_string())); - } - - transaction.execute( - "UPDATE notes SET space_id = ?2 WHERE space_id = ?1", - (id, target_id), - )?; - transaction.execute("DELETE FROM spaces WHERE id = ?1", [id])?; - - transaction.commit()?; - - Ok(()) +pub fn delete( + connection: &mut SqliteConnection, + id: &str, + target_id: &str, +) -> Result<(), StorageError> { + connection.transaction(|connection| { + if !exists(connection, id)? { + return Err(StorageError::SpaceNotFound(id.to_string())); + } + if !exists(connection, target_id)? { + return Err(StorageError::SpaceNotFound(target_id.to_string())); + } + + diesel::update(notes::table.filter(notes::space_id.eq(id))) + .set(notes::space_id.eq(target_id)) + .execute(connection)?; + diesel::delete(spaces::table.find(id)).execute(connection)?; + + Ok(()) + }) } #[cfg(test)] @@ -141,12 +155,42 @@ mod tests { use super::*; use crate::storage::open_in_memory; + const T0: &str = "2026-07-25T09:00:00.000Z"; + + /// Note posée directement en base : ces tests portent sur les espaces, et + /// passer par `notes::create` y ferait entrer ses propres règles. + fn note_in(connection: &mut SqliteConnection, space_id: &str) { + diesel::insert_into(notes::table) + .values(( + notes::id.eq("n-1"), + notes::space_id.eq(space_id), + notes::title.eq("A"), + notes::language.eq("txt"), + notes::content.eq(""), + notes::source.eq(""), + notes::pinned.eq(false), + notes::created_at.eq(T0), + notes::updated_at.eq(T0), + notes::lifecycle_kind.eq("permanent"), + )) + .execute(connection) + .unwrap(); + } + + fn names(connection: &mut SqliteConnection) -> Vec { + list(connection) + .unwrap() + .into_iter() + .map(|space| space.name) + .collect() + } + #[test] fn a_created_space_is_listed_back() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - let created = create(&connection, "Perso").unwrap(); - let listed = list(&connection).unwrap(); + let created = create(&mut connection, "Perso").unwrap(); + let listed = list(&mut connection).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, created.id); @@ -155,93 +199,88 @@ mod tests { #[test] fn each_space_gets_its_own_identifier() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - let first = create(&connection, "Perso").unwrap(); - let second = create(&connection, "Boulot").unwrap(); + let first = create(&mut connection, "Perso").unwrap(); + let second = create(&mut connection, "Boulot").unwrap(); assert_ne!(first.id, second.id); } #[test] fn spaces_are_listed_in_name_order() { - let connection = open_in_memory().unwrap(); - - create(&connection, "Veille").unwrap(); - create(&connection, "Boulot").unwrap(); - create(&connection, "perso").unwrap(); + let mut connection = open_in_memory().unwrap(); - let names: Vec = list(&connection) - .unwrap() - .into_iter() - .map(|s| s.name) - .collect(); + create(&mut connection, "Veille").unwrap(); + create(&mut connection, "Boulot").unwrap(); + create(&mut connection, "perso").unwrap(); - assert_eq!(names, ["Boulot", "perso", "Veille"]); + // Case-insensitive: a BINARY sort would file "perso" after "Veille". + assert_eq!(names(&mut connection), ["Boulot", "perso", "Veille"]); } #[test] fn a_duplicate_name_is_refused_regardless_of_case() { - let connection = open_in_memory().unwrap(); - create(&connection, "Perso").unwrap(); + let mut connection = open_in_memory().unwrap(); + create(&mut connection, "Perso").unwrap(); - let error = create(&connection, "PERSO").unwrap_err(); + let error = create(&mut connection, "PERSO").unwrap_err(); assert!(matches!(error, StorageError::DuplicateSpaceName(_))); - assert_eq!(list(&connection).unwrap().len(), 1); + assert_eq!(list(&mut connection).unwrap().len(), 1); } #[test] fn exists_distinguishes_known_from_unknown_identifiers() { - let connection = open_in_memory().unwrap(); - let space = create(&connection, "Perso").unwrap(); + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "Perso").unwrap(); - assert!(exists(&connection, &space.id).unwrap()); - assert!(!exists(&connection, "inconnu").unwrap()); + assert!(exists(&mut connection, &space.id).unwrap()); + assert!(!exists(&mut connection, "inconnu").unwrap()); } #[test] fn a_renamed_space_keeps_its_identifier() { - let connection = open_in_memory().unwrap(); - let space = create(&connection, "Perso").unwrap(); + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "Perso").unwrap(); - let renamed = rename(&connection, &space.id, "Personnel").unwrap(); + let renamed = rename(&mut connection, &space.id, "Personnel").unwrap(); // The id is what the notes point at: changing it would orphan them. assert_eq!(renamed.id, space.id); assert_eq!(renamed.name, "Personnel"); - assert_eq!(list(&connection).unwrap()[0].name, "Personnel"); + assert_eq!(list(&mut connection).unwrap()[0].name, "Personnel"); } #[test] fn a_space_can_be_renamed_to_a_different_case_of_its_own_name() { - let connection = open_in_memory().unwrap(); - let space = create(&connection, "perso").unwrap(); + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "perso").unwrap(); // The uniqueness check is COLLATE NOCASE: without excluding the row // being renamed, it would see the space as a duplicate of itself. - let renamed = rename(&connection, &space.id, "Perso").unwrap(); + let renamed = rename(&mut connection, &space.id, "Perso").unwrap(); assert_eq!(renamed.name, "Perso"); } #[test] fn renaming_onto_another_space_name_is_refused() { - let connection = open_in_memory().unwrap(); - create(&connection, "Boulot").unwrap(); - let space = create(&connection, "Perso").unwrap(); + let mut connection = open_in_memory().unwrap(); + create(&mut connection, "Boulot").unwrap(); + let space = create(&mut connection, "Perso").unwrap(); - let error = rename(&connection, &space.id, "BOULOT").unwrap_err(); + let error = rename(&mut connection, &space.id, "BOULOT").unwrap_err(); assert!(matches!(error, StorageError::DuplicateSpaceName(_))); - assert_eq!(list(&connection).unwrap()[1].name, "Perso"); + assert_eq!(list(&mut connection).unwrap()[1].name, "Perso"); } #[test] fn renaming_an_unknown_space_reports_an_error() { - let connection = open_in_memory().unwrap(); + let mut connection = open_in_memory().unwrap(); - let error = rename(&connection, "inconnu", "Perso").unwrap_err(); + let error = rename(&mut connection, "inconnu", "Perso").unwrap_err(); assert!(matches!(error, StorageError::SpaceNotFound(_))); } @@ -249,74 +288,57 @@ mod tests { #[test] fn deleting_a_space_moves_its_notes_to_the_target() { let mut connection = open_in_memory().unwrap(); - let doomed = create(&connection, "Perso").unwrap(); - let refuge = create(&connection, "Boulot").unwrap(); - connection - .execute( - "INSERT INTO notes VALUES ('n-1', ?1, 'A', 'txt', '', '', 0, \ - '2026-07-25T09:00:00.000Z', '2026-07-25T09:00:00.000Z', 'permanent', NULL)", - [&doomed.id], - ) - .unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + note_in(&mut connection, &doomed.id); delete(&mut connection, &doomed.id, &refuge.id).unwrap(); // The schema cascades on space deletion; the move must happen first or // the note disappears with its space. - let space_id: String = connection - .query_row("SELECT space_id FROM notes WHERE id = 'n-1'", [], |row| { - row.get(0) - }) + let space_id = notes::table + .find("n-1") + .select(notes::space_id) + .first::(&mut connection) .unwrap(); assert_eq!(space_id, refuge.id); - assert_eq!(list(&connection).unwrap().len(), 1); + assert_eq!(list(&mut connection).unwrap().len(), 1); } #[test] fn moving_notes_out_of_a_deleted_space_does_not_touch_their_timestamps() { let mut connection = open_in_memory().unwrap(); - let doomed = create(&connection, "Perso").unwrap(); - let refuge = create(&connection, "Boulot").unwrap(); - connection - .execute( - "INSERT INTO notes VALUES ('n-1', ?1, 'A', 'txt', '', '', 0, \ - '2026-07-25T09:00:00.000Z', '2026-07-25T09:00:00.000Z', 'permanent', NULL)", - [&doomed.id], - ) - .unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + note_in(&mut connection, &doomed.id); delete(&mut connection, &doomed.id, &refuge.id).unwrap(); // The canvas orders on updated_at: refreshing it would float the whole // absorbed space to the top as if every note had just been edited. - let updated_at: String = connection - .query_row("SELECT updated_at FROM notes WHERE id = 'n-1'", [], |row| { - row.get(0) - }) + let updated_at = notes::table + .find("n-1") + .select(notes::updated_at) + .first::(&mut connection) .unwrap(); - assert_eq!(updated_at, "2026-07-25T09:00:00.000Z"); + assert_eq!(updated_at, T0); } #[test] fn deleting_an_empty_space_leaves_the_others_alone() { let mut connection = open_in_memory().unwrap(); - let doomed = create(&connection, "Perso").unwrap(); - let refuge = create(&connection, "Boulot").unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); delete(&mut connection, &doomed.id, &refuge.id).unwrap(); - let names: Vec = list(&connection) - .unwrap() - .into_iter() - .map(|s| s.name) - .collect(); - assert_eq!(names, ["Boulot"]); + assert_eq!(names(&mut connection), ["Boulot"]); } #[test] fn deleting_an_unknown_space_reports_an_error() { let mut connection = open_in_memory().unwrap(); - let refuge = create(&connection, "Boulot").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); let error = delete(&mut connection, "inconnu", &refuge.id).unwrap_err(); @@ -326,24 +348,21 @@ mod tests { #[test] fn deleting_into_an_unknown_space_changes_nothing() { let mut connection = open_in_memory().unwrap(); - let doomed = create(&connection, "Perso").unwrap(); - connection - .execute( - "INSERT INTO notes VALUES ('n-1', ?1, 'A', 'txt', '', '', 0, \ - '2026-07-25T09:00:00.000Z', '2026-07-25T09:00:00.000Z', 'permanent', NULL)", - [&doomed.id], - ) - .unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + note_in(&mut connection, &doomed.id); let error = delete(&mut connection, &doomed.id, "inconnu").unwrap_err(); // Rolling back matters here: a half-applied delete would have taken the // notes with it. assert!(matches!(error, StorageError::SpaceNotFound(_))); - assert_eq!(list(&connection).unwrap().len(), 1); - let remaining: i64 = connection - .query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0)) - .unwrap(); - assert_eq!(remaining, 1); + assert_eq!(list(&mut connection).unwrap().len(), 1); + assert_eq!( + notes::table + .count() + .get_result::(&mut connection) + .unwrap(), + 1 + ); } } From d94066ed9b847b70db47b5789b8752737a65056f Mon Sep 17 00:00:00 2001 From: Valentin MILLET Date: Sun, 9 Aug 2026 12:02:55 +0200 Subject: [PATCH 3/6] Remove `rules.rs` and its tests, simplify `bindings.ts`, enhance strict enumeration for `language`, refactor DTOs and CI checks for streamlined dependency direction. --- .github/actions/setup-rust/action.yml | 13 +- .github/workflows/ci.yml | 13 +- CLAUDE.md | 15 +- docs/architecture.md | 28 +- rust-toolchain.toml | 10 + scripts/check-layers.sh | 47 + src-tauri/Cargo.lock | 124 +- src-tauri/Cargo.toml | 47 +- src-tauri/build.rs | 2 +- src-tauri/clippy.toml | 3 + src-tauri/src/bin/export-bindings.rs | 7 +- src-tauri/src/commands.rs | 27 + src-tauri/src/commands/error.rs | 113 +- src-tauri/src/commands/mod.rs | 68 - src-tauri/src/commands/notes.rs | 22 +- src-tauri/src/commands/spaces.rs | 28 +- src-tauri/src/commands/tests.rs | 40 + src-tauri/src/commands/tray.rs | 26 +- src-tauri/src/desktop.rs | 175 +-- src-tauri/src/desktop/shortcut.rs | 44 + src-tauri/src/desktop/tray.rs | 101 ++ src-tauri/src/domain.rs | 15 + src-tauri/src/domain/detect.rs | 559 -------- src-tauri/src/domain/error.rs | 23 + src-tauri/src/domain/fixtures.rs | 30 + src-tauri/src/domain/iso8601.rs | 59 + src-tauri/src/domain/language.rs | 123 ++ src-tauri/src/domain/language/detect.rs | 275 ++++ src-tauri/src/domain/language/detect/tests.rs | 251 ++++ src-tauri/src/domain/mod.rs | 39 - src-tauri/src/domain/note.rs | 385 ++--- src-tauri/src/domain/note/tests.rs | 182 +++ src-tauri/src/domain/rules.rs | 197 --- src-tauri/src/domain/search.rs | 51 + src-tauri/src/domain/section.rs | 120 ++ src-tauri/src/domain/section/tests.rs | 273 ++++ src-tauri/src/domain/sections.rs | 420 ------ src-tauri/src/domain/space.rs | 37 +- src-tauri/src/domain/tag.rs | 69 + src-tauri/src/domain/view.rs | 364 +---- src-tauri/src/domain/view/tests.rs | 144 ++ src-tauri/src/lib.rs | 94 +- src-tauri/src/main.rs | 2 +- src-tauri/src/storage.rs | 75 + src-tauri/src/storage/error.rs | 34 + src-tauri/src/storage/migration.rs | 106 ++ src-tauri/src/storage/migration/tests.rs | 171 +++ src-tauri/src/storage/mod.rs | 418 ------ src-tauri/src/storage/notes.rs | 1247 ++--------------- src-tauri/src/storage/schema.rs | 18 +- src-tauri/src/storage/spaces.rs | 259 +--- src-tauri/tests/ipc_contract.rs | 381 +++++ src-tauri/tests/notes.rs | 1079 ++++++++++++++ src-tauri/tests/spaces.rs | 220 +++ src/app/core/ipc/bindings.ts | 154 +- src/app/core/language/language.model.ts | 19 +- src/app/features/notes/data/note.dto.spec.ts | 11 +- src/app/features/notes/data/note.dto.ts | 15 +- .../features/notes/data/note.dto.view.spec.ts | 9 - 59 files changed, 4603 insertions(+), 4278 deletions(-) create mode 100644 rust-toolchain.toml create mode 100644 scripts/check-layers.sh create mode 100644 src-tauri/clippy.toml create mode 100644 src-tauri/src/commands.rs delete mode 100644 src-tauri/src/commands/mod.rs create mode 100644 src-tauri/src/commands/tests.rs create mode 100644 src-tauri/src/desktop/shortcut.rs create mode 100644 src-tauri/src/desktop/tray.rs create mode 100644 src-tauri/src/domain.rs delete mode 100644 src-tauri/src/domain/detect.rs create mode 100644 src-tauri/src/domain/error.rs create mode 100644 src-tauri/src/domain/fixtures.rs create mode 100644 src-tauri/src/domain/iso8601.rs create mode 100644 src-tauri/src/domain/language.rs create mode 100644 src-tauri/src/domain/language/detect.rs create mode 100644 src-tauri/src/domain/language/detect/tests.rs delete mode 100644 src-tauri/src/domain/mod.rs create mode 100644 src-tauri/src/domain/note/tests.rs delete mode 100644 src-tauri/src/domain/rules.rs create mode 100644 src-tauri/src/domain/search.rs create mode 100644 src-tauri/src/domain/section.rs create mode 100644 src-tauri/src/domain/section/tests.rs delete mode 100644 src-tauri/src/domain/sections.rs create mode 100644 src-tauri/src/domain/tag.rs create mode 100644 src-tauri/src/domain/view/tests.rs create mode 100644 src-tauri/src/storage.rs create mode 100644 src-tauri/src/storage/error.rs create mode 100644 src-tauri/src/storage/migration.rs create mode 100644 src-tauri/src/storage/migration/tests.rs delete mode 100644 src-tauri/src/storage/mod.rs create mode 100644 src-tauri/tests/ipc_contract.rs create mode 100644 src-tauri/tests/notes.rs create mode 100644 src-tauri/tests/spaces.rs diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 1845ad0..e485370 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -23,8 +23,19 @@ runs: build-essential curl file libayatana-appindicator3-dev libgtk-3-dev \ librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf wget - - uses: dtolnay/rust-toolchain@stable + # Version lue depuis `rust-toolchain.toml` plutôt qu'écrite ici : deux endroits + # finiraient par diverger, et c'est justement la dérive que l'épinglage évite. + - name: Version de la toolchain + id: toolchain + shell: bash + run: | + channel=$(sed -n 's/^channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' rust-toolchain.toml) + echo "channel=$channel" >> "$GITHUB_OUTPUT" + echo "Toolchain épinglée : $channel" + + - uses: dtolnay/rust-toolchain@master with: + toolchain: ${{ steps.toolchain.outputs.channel }} components: clippy, rustfmt # `workspaces` est obligatoire : le projet Cargo est dans src-tauri/, pas à la racine. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea905cf..8cb1359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,12 +60,10 @@ jobs: - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --locked -- -D warnings - # Les deux gardes de direction des dépendances documentées dans CLAUDE.md : - # domain/ ne connaît ni rusqlite ni Tauri, storage/ ne remonte pas vers commands/. + # Le backend tient en un seul crate : ce script est ce qui tient la direction + # des dépendances entre couches. Exécutable en local, d'où le fichier. - name: Garde des dépendances entre couches - run: | - ! grep -rn "rusqlite\|tauri::" src-tauri/src/domain/ - ! grep -rn "use crate::commands" src-tauri/src/storage/ + run: bash scripts/check-layers.sh build-front: name: Build front @@ -173,8 +171,9 @@ jobs: steps: - uses: actions/checkout@v4 - # Restaure le target/ de `build-rust` — dont la compilation de SQLite (rusqlite - # `bundled` compile les sources C), qui est l'essentiel du temps de ce job. + # Restaure le target/ de `build-rust` — dont la compilation de SQLite + # (`libsqlite3-sys` en `bundled` compile les sources C), qui est l'essentiel + # du temps de ce job. - uses: ./.github/actions/setup-rust with: shared-key: rust-ubuntu diff --git a/CLAUDE.md b/CLAUDE.md index c05e7bd..ab3acd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ The **notes feature is complete end to end**: front-end (spaces with creation, r - `src-tauri/src/storage/` — SQLite through Diesel (`libsqlite3-sys` `bundled`, database in `app_data_dir()`). Queries only, zero business rules. - `src-tauri/src/commands/` — Tauri adapters: validate, lock, delegate, translate the error. A command that grows means a rule landed in the wrong place. -Two greps guard the direction, and are worth running after any structural change: `grep -rn "diesel\|tauri::" src-tauri/src/domain/` and `grep -rn "use crate::commands" src-tauri/src/storage/` must both come back empty. `docs/architecture.md` has the details. +`bash scripts/check-layers.sh` guards the direction and is worth running after any structural change — CI runs the same script. In one crate nothing in the language enforces this, so that script is the guarantee. `docs/architecture.md` has the details. **The front-end is filed by subject, not by technical nature.** A feature owns its `data/`, `model/`, `state/` and `ui/` — `features/notes/` holds the DTOs, the repositories, the models, both stores and every notes component, so deleting the folder deletes the feature. `core/` is only what a second, unrelated tool would inject verbatim (`ipc`, `i18n`, `errors`, `time`, `preferences`, `updates`, `app-info`, `language`), one folder per subject with a service and its store together — there is no `core/stores/`. `shared/` is a presentation kit whose components **inject nothing**; anything that injects and frames the app belongs to `layout/`. Adding the hashing tool must not add a file under `core/`. @@ -37,11 +37,11 @@ Run all commands from the repo root (`package.json` there wraps both Angular and - `npm test` — Angular unit tests via the `@angular/build:unit-test` builder with **Vitest** (jsdom, no browser required). `npm run test:watch` re-runs on change; `npm run test:coverage` adds a v8 coverage report with 80% thresholds. - `npm run lint` — ESLint (with `angular-eslint`, including its template accessibility rules) plus a Prettier format check. `npm run lint:fix` fixes what it can; `npm run format` runs Prettier alone. -- `cargo test` from `src-tauri/` — persistence and serialisation tests (no extra setup; they run against an in-memory SQLite database). +- `cargo test` from `src-tauri/` — unit tests live beside the code in sibling `tests.rs` files; `src-tauri/tests/` holds the three integration binaries (`notes`, `spaces`, `ipc_contract`), which see only the crate's public API. No extra setup: they run against an in-memory SQLite database. - `npm run bindings` — regenerates `src/app/core/ipc/bindings.ts` from the Rust signatures without launching the app. `npm run tauri dev` does it too, at every launch. -- `cargo clippy -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` sets `unsafe_code = "forbid"` and `deny(clippy::all)`. +- `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` forbids `unsafe_code`, denies `clippy::all` and warns on `clippy::pedantic`, `rust_2018_idioms` and `unreachable_pub`. The toolchain is pinned in `rust-toolchain.toml`, so a new stable release can't turn CI red on an untouched commit. ## Things that will bite you @@ -49,11 +49,12 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **The IPC surface is generated.** `src/app/core/ipc/bindings.ts` comes from tauri-specta: one typed function per command plus a TS type per struct crossing the bridge. It is committed and regenerated by `npm run tauri dev` or `npm run bindings` (the `export-bindings` binary). Adding a command means annotating it `#[tauri::command]` **and** `#[specta::specta]`, adding it to `collect_commands![...]` in `src-tauri/src/lib.rs` — the single list, it both registers with Tauri and drives the generation — then regenerating. Every type crossing the bridge derives `specta::Type`. Specta refuses `usize`/`i64`/… (JSON precision), hence `NotesView.matched: u32`. The generator is _not_ wired as a `#[test]`: on Windows the test exe lives in `target/debug/deps/`, without the `WebView2Loader.dll` that linking `Builder::export` then needs, and the whole test binary fails to start. - **Calls return a Result, not a rejection.** `commands.queryNotes(q)` gives `{ status: 'ok' | 'error' }`. Repositories run it through `unwrap()` (`core/ipc/ipc.error.ts`), which returns the data or throws an `IpcError` — stores and components keep their `try`/`catch`. Only `features/notes/data/` and `core/ipc/` import `bindings.ts`; everything else uses the DTO aliases re-exported from `note.dto.ts`. -- **Serialisation contract.** The camelCase and `tag = "kind"` serde attributes are still load-bearing, but specta reads them, so the TS side follows automatically. What generation does _not_ cover, and what `features/notes/data/note.dto.ts` still exists for: JSON has no date type (every `Date` crosses as an ISO string), `language` is a free `String` in Rust that the front narrows to a `LanguageTag`, and a patch omits the keys it does not touch (hence `#[specta(optional)]` on every `NotePatch` field — without it the generated type would demand explicit `null`s, which overwrite). +- **Serialisation contract.** The camelCase and `tag = "kind"` serde attributes are still load-bearing, but specta reads them, so the TS side follows automatically. What generation does _not_ cover, and what `features/notes/data/note.dto.ts` still exists for: JSON has no date type (the domain holds `DateTime`, which crosses as an ISO string and the front turns back into a `Date`), and a patch omits the keys it does not touch (hence `#[specta(optional)]` on every `NotePatch` field — without it the generated type would demand explicit `null`s, which overwrite). `language` no longer needs anything: it is a Rust enum, so the bindings hand the front a real union and `LanguageTag` is a plain alias of it. +- **Instants: one formatter, and it protects the column, not the wire.** `domain/iso8601.rs` always writes milliseconds. `created_at` / `updated_at` are TEXT columns sorted lexicographically and the canvas orders on them: `.` (0x2E) precedes `Z` (0x5A), so `09:00:00.500Z` would sort _before_ `09:00:00Z` — which is exactly what chrono's default `SecondsFormat::AutoSi` produces. The wire is free of this: the front reads every date with `new Date(iso)`. - **Errors are codes, not strings.** Commands return `Result` (`commands/error.rs`): a stable `code`, its interpolation `params`, and a technical `detail`. Returning a `String` would put a French sentence in the English UI and force callers to parse prose. `IpcErrorCode` is now a plain alias of the **generated** `ErrorCode`, so adding a Rust variant breaks two tables until it is handled: `CODE_KEYS` in `core/errors/error-notifier.service.ts` (which needs a key in **both** locales) and `IPC_ERROR_CODES` in `ipc.error.ts`. That second one is a runtime guard and still earns its keep: the bindings _declare_ the error branch as an `AppError`, but Tauri rejects with a plain string for an unknown command or a bad argument, and that lands in the same branch — `IpcError.code` is `null` there. -- **Input is validated in the domain, not just in the form.** `domain/rules.rs`; commands call `draft.validate()` / `validated_name()` before locking. A rule held only by a form is not held. +- **Input is validated in the domain, not just in the form.** `domain/error.rs` carries `ValidationError`; `SpaceDraft::validated_name` and `space::validate_move_target` run before locking. A rule held only by a form is not held. Language needs no validation any more — an unknown value fails deserialisation at the bridge, and cannot be written on the front at all. - **Data-source seam.** Components and stores never touch a data source directly: everything goes through `NotesRepository` / `SpacesRepository`, plain `providedIn: 'root'` classes — no interface, no `InjectionToken`, nothing bound in `app.config.ts`, because there is exactly one implementation. Specs substitute them by class (`{ provide: NotesRepository, useValue: fake }`, via `provideAppTesting()`); the fakes in `src/testing/` keep their compile-time check with `implements Pick`. Don't call a generated command from a component or a store — the repositories are the only callers. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front. -- **A conversion exists only where the wire shape differs from the domain shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: wire types (now generated aliases), repository, conversion. Notes need theirs (`Date` ↔ ISO, `string` → `LanguageTag`, patch copied field by field) and it lives in `features/notes/data/note.dto.ts`. Spaces don't: `Space` crosses the bridge as itself. Don't reintroduce an identity mapper for symmetry. The section key no longer needs a runtime guard either — `NoteSectionKey` is generated, so a variant added in Rust is a compile error. +- **A conversion exists only where the wire shape differs from the domain shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: wire types (now generated aliases), repository, conversion. Notes need theirs (`Date` ↔ ISO, patch copied field by field) and it lives in `features/notes/data/note.dto.ts`. Spaces don't: `Space` crosses the bridge as itself. Don't reintroduce an identity mapper for symmetry. The section key no longer needs a runtime guard either — `NoteSectionKey` is generated, so a variant added in Rust is a compile error. - **`null` space means "all spaces".** `SpacesStore.activeSpaceId()` is `null` when the user wants every space, and that is a choice, not a loading state — don't add an "All" row to the spaces data, notes would end up filed into it. A note always has a `spaceId`; creating one with no space available is refused on purpose. - **Deleting a space needs a refuge.** `notes.space_id` carries `ON DELETE CASCADE`, so `delete_space(id, targetSpaceId)` moves the notes _then_ deletes, in one transaction — there is no one-argument variant, which would have made data loss the default. It leaves `updated_at` alone (the canvas sorts on it, and touching it would float the whole absorbed space to the top). A space can't be its own refuge: `domain::space::validate_move_target` refuses it before any SQL runs. `targetSpaceId` is the first multi-word command argument, so it's the one that actually exercises Tauri's camelCase renaming. - **"À trier" = a note with a deadline.** The `untriaged` filter, the `⏳` badge and the "à trier bientôt" section hint all read the same field, `lifecycle`. It's set from the editor's date field, converted to the **end of the local day** (`endOfLocalDay`) — midnight would make a note dated today expired on the spot — and read back in local time too. Remove that field and all three affordances go permanently empty, which is exactly the state they were in before it existed. @@ -72,7 +73,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **The domain structs are a _contract_.** Don't change the shape of anything in `domain/note.rs`, `view.rs` or `space.rs` without changing the DTOs on the front; their serde tests will fail if you do. - **Search matching is Rust, not SQL.** SQLite's `LOWER()` only folds ASCII without ICU, so a `WHERE LOWER(title) LIKE …` would stop matching `Étape` against `étape`. Coarse filters (space, pin, lifecycle, language, tags) stay in SQL where they're indexed; text matching runs on the fetched rows via `to_lowercase()`. - **Syntax highlighting is highlight.js, front-side, in exactly one module.** `shared/ui/code-viewer/highlighter.ts` imports grammars **one by one** (`highlight.js/lib/languages/…`), never the default bundle. It colours the whole block — that's what handles multi-line comments and strings — then re-splits the output with `splitHighlightedLines`, which reopens the tag stack across each newline. The `.hljs-*` theme lives in the **global** `src/styles/_code-theme.scss`: injected by `[innerHTML]`, it carries no `_ngcontent` attribute, so a component-scoped rule would never match. -- **Tag normalisation lives in `domain::rules::normalize_tags`, and only there.** Trim, strip leading `#`, drop blanks, collapse case-insensitive duplicates. The front sends the raw string. `storage::notes::replace_tags` calls it and sorts the result to match what a read gives back, or a note's tags reorder themselves on the next reload. `note_tags.tag` is `COLLATE NOCASE` (migration 2) so the folding extends across notes, not just within one. +- **Tag normalisation lives in `domain::tag::normalize`, and only there.** Trim, strip leading `#`, drop blanks, collapse case-insensitive duplicates. `NoteDraft::into_note` and `NotePatch::apply` call it; `storage::notes::replace_tags` receives tags already normalised and is pure SQL. It **re-reads** them after writing rather than sorting: `note_tags.tag` is `COLLATE NOCASE` (migration 2) and a read orders in that collation, which a byte-wise `sort()` does not reproduce — `Urgent` would come back before `auth` on write and after it on reload. - **A `computed` feeding a `resource` needs an `equal` comparator.** `resource` compares params by identity. `NotesStore.queryParams` returns a fresh object literal and reads `clock.now()`: without `sameQueryParams`, every 30 s tick fired a full `query_notes` round trip, invisible behind the retained view. - **Migrations are append-only.** They are SQL files under `src-tauri/migrations/`, embedded by `embed_migrations!` and tracked in `__diesel_schema_migrations`. Changing the model means a new `YYYY-MM-DD-HHMMSS_name/` directory with an `up.sql` — never editing a shipped one, it has already run on existing installs. `storage::adopt_legacy_history` bridges databases still versioned by the old `PRAGMA user_version` (1..3): it marks the matching migrations as applied and zeroes the pragma, so nothing is replayed. Deleting the database file is a legitimate reset during development (`app_data_dir()/devbox.sqlite3`). - **`storage/schema.rs` is hand-written, not `diesel print-schema`.** Generating it would make `cargo check` depend on an up-to-date database outside the repo. Adding a column means editing **both** the migration SQL and this file; `check_for_backend` on `NoteRow` turns a divergence into a compile error. Diesel does not model the `CHECK`s, the `ON DELETE CASCADE`s or the `NOCASE` collation — those live in the migration SQL and are simply obeyed. Where a collation must be applied to an expression rather than a column (`spaces.name`), the query drops to a `diesel::dsl::sql` fragment; that is deliberate, not a gap to tidy up. diff --git a/docs/architecture.md b/docs/architecture.md index edf9107..9b2f935 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,8 +36,8 @@ src/ Angular front-end ├── styles/ global theme (styles.scss) and SCSS partials └── testing/ test doubles, fixtures and shared providers src-tauri/ Rust back-end -├── src/domain/ model and business rules — knows neither SQLite nor Tauri -│ (note, view, sections, space, rules) +├── src/domain/ model and business rules — knows neither Diesel nor Tauri +│ (note, view, section, space, language, tag, search, error, iso8601) ├── src/storage/ SQLite persistence: schema, migrations, SQL only ├── src/commands/ Tauri adapters: lock, delegate, translate the error ├── src/lib.rs Tauri builder, database setup + command registration @@ -51,7 +51,7 @@ knows how to read and write the model, the transport layer knows how to serialis neither one defines it. (Before the domain layer existed, the model lived in `commands/` and `storage/` imported it from there, which pointed persistence at transport.) -Two greps enforce it, and are worth running after any structural change: +`bash scripts/check-layers.sh` enforces it — CI runs the same script, and in a single crate nothing in the language does. Worth running after any structural change: ```bash grep -rn "diesel\|tauri::" src-tauri/src/domain/ # must be empty @@ -68,9 +68,13 @@ choice and expiry thresholds in a few milliseconds, with no fixture setup. | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | `note.rs` | what a note **is** (`Note`, lifecycle, draft, patch) and how it is **shown** (`NoteFooter`, `DisplayNote`, `decorate`) | | `view.rs` | what is asked (`NotesQuery`, `NoteFilter`) and what comes back (`NotesView`, `NoteSection`), plus `build()` | -| `sections.rs` | chronological placement and the timezone arithmetic it needs | +| `section.rs` | chronological placement and the timezone arithmetic it needs | | `space.rs` | the space and its move-target rule | -| `rules.rs` | validation and matching: `ValidationError`, languages, tag normalisation, search | +| `error.rs` | `ValidationError` | +| `language.rs` | the closed `Language` enum, and `language/detect.rs` which guesses one from pasted content | +| `tag.rs` | tag normalisation | +| `search.rs` | full-text matching | +| `iso8601.rs` | the stored-instant format — millisecond-exact, because the canvas sorts on a TEXT column | The split is deliberately coarse. A module per function meant four files wrapping one function each, four module headers, and a reader chasing `normalize` across the tree. @@ -292,7 +296,7 @@ Rules of the house: ### Display sections -Sections are built in Rust (`src-tauri/src/domain/sections.rs`) and arrive ready to render. +Sections are built in Rust (`src-tauri/src/domain/section.rs`) and arrive ready to render. The front-end preserves the order it receives and never drops or merges a section. The classification into `pinned`, `today`, `week` and `older` is **exhaustive**: apart from @@ -544,7 +548,7 @@ biggest file in `data/`: The mapper parses it into a `Date` and throws a `ContractError` on an unparseable value, rather than letting an `Invalid Date` propagate and resurface as `NaN` in a relative-time label. The reverse direction (`toIsoString`) guards the same way. -- **The front narrows what Rust leaves wide.** `language` is a free `String` in the domain; +- **The front no longer narrows the language.** It was a free `String` in the domain; the front restricts it to a `LanguageTag`. - **A patch omits what it does not touch.** The Rust fields carry `#[specta(optional)]`, so the generated `NotePatch` has optional keys and `toNotePatchDto` can copy field by field — @@ -561,7 +565,7 @@ this front-end build does not — and since Rust types it as a plain string, the rule it out. A section key needs no such guard any more: `NoteSectionKey` is generated, so a variant added in Rust breaks the assignment at compile time instead of throwing at runtime. -The known list is `domain/rules.rs` (`LANGUAGES`), mirrored by `core/language/language.model.ts` +The known list is `domain/language.rs` (`Language`), aliased by `core/language/language.model.ts` (`LanguageTag` + `LANGUAGE_LABELS`). Adding a language means editing both, plus a `.lang-*` rule in `language-badge.component.scss` and, if it should be coloured, an entry in `GRAMMARS`. Nothing compares the two lists, so a drift only surfaces at runtime as a fallback to `txt`. @@ -577,7 +581,7 @@ who remember to touch the select. Three things keep it honest: - `language_after_patch` when a patch gives a **still-empty** note its content — the ordinary "+ New note, then paste", where creation sees no content at all. Applied from `storage::notes::update`, which calls into the domain for the rule the same way it calls - `rules::normalize_tags`. + `domain::tag::normalize`. - It **never replays afterwards**. Once a note has content, or carries a language other than `txt`, or the patch sets a language itself, nothing is guessed: re-detecting on every write would take the select back from the user, and there would be no way to overrule a bad guess. @@ -684,7 +688,7 @@ passes if it carries _at least one_ of the selected values), facets scoped to th than to the current filter, and a selection counts as `is_filtering` — which collapses the canvas into a single flat `results` section. The quick filters (pinned / untriaged) do not: they narrow a view that stays chronological. One asymmetry: selected tags go through -`domain::rules::normalize_tags` before hitting SQL, selected languages do not — a language is picked +`domain::tag::normalize` before hitting SQL, selected languages do not — a language is picked from a closed list, not typed, and `domain::language` compares it exactly. The serialisation contract is pinned by tests in `domain/note.rs`, `domain/query.rs`, @@ -697,7 +701,7 @@ error code serialises to `"noteNotFound"`. A serde attribute deleted by accident ### Input validation The back validates what the front already constrains, because a rule held only by a form is -not held at all. `domain/rules.rs` defines a `ValidationError` carrying the offending +not held at all. `domain/error.rs` defines a `ValidationError` carrying the offending `field`; commands call `draft.validate()` / `draft.validated_name()` before touching the connection, and `AppError` turns the refusal into `invalidInput` with `{{field}}`. @@ -760,7 +764,7 @@ installed or shipped alongside the executable. The database file lives in Tauri' matching is done **in Rust** (`domain::search`), because SQLite's `LOWER()` only folds ASCII without ICU, so `Étape` would not match `étape`. Grouping is `domain::sections`, which touches no connection and is therefore testable without a database. -- **Tag normalisation lives in `domain::rules::normalize_tags`, and only there.** Trimming, +- **Tag normalisation lives in `domain::tag::normalize`, and only there.** Trimming, stripping leading `#`, dropping blanks and collapsing case-insensitive duplicates (first spelling wins) all happen on write, so the front sends what the user typed. The returned tags are sorted to match what a read gives back — otherwise a note's tags would reorder diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c44880e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +# Toolchain épinglée : la CI prenait `@stable`, qui bouge sous les pieds et fait +# apparaître des lints clippy d'un jour à l'autre sans qu'aucun commit n'ait bougé. +# La relever est un commit délibéré. +# +# À la racine et non dans `src-tauri/` malgré le crate : rustup résout ce fichier +# depuis le répertoire courant en remontant, et la CI lance cargo depuis la racine +# avec `--manifest-path`. +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] diff --git a/scripts/check-layers.sh b/scripts/check-layers.sh new file mode 100644 index 0000000..46deaca --- /dev/null +++ b/scripts/check-layers.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Direction des dépendances entre les couches Rust : commands/ → domain/ ← storage/. +# +# Le backend tient en un seul crate, donc rien dans le langage n'impose ce sens : +# ce script est le garde. Appelé par la CI, et exécutable en local avant de +# pousser — `bash scripts/check-layers.sh`. +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +status=0 + +# Les chemins absents sont ignorés : `domain/` s'écrit `domain.rs` + `domain/`, +# et l'un des deux peut manquer selon le découpage. +check() { + local label="$1" pattern="$2" + shift 2 + + local targets=() + for path in "$@"; do + [ -e "$root/$path" ] && targets+=("$root/$path") + done + + if [ ${#targets[@]} -eq 0 ]; then + echo "✗ $label — aucun des chemins surveillés n'existe : $*" + status=1 + return + fi + + local found + if found=$(grep -rn "$pattern" "${targets[@]}"); then + echo "✗ $label" + echo "$found" | sed 's|^| |' + status=1 + else + echo "✓ $label" + fi +} + +check "domain/ ne connaît ni Diesel ni Tauri" \ + 'diesel\|tauri::' \ + src-tauri/src/domain.rs src-tauri/src/domain + +check "storage/ ne remonte pas vers commands/" \ + 'use crate::commands' \ + src-tauri/src/storage.rs src-tauri/src/storage + +exit $status diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 63eb455..b97bdb1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -38,6 +38,23 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -439,7 +456,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -804,6 +821,7 @@ dependencies = [ "diesel", "diesel_migrations", "libsqlite3-sys", + "log", "serde", "serde_json", "specta", @@ -812,11 +830,13 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-global-shortcut", + "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", "tauri-specta", + "thiserror 2.0.20", "uuid", ] @@ -1088,6 +1108,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1162,6 +1192,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1592,7 +1631,7 @@ dependencies = [ "objc2-app-kit", "once_cell", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.59.0", "x11rb", "xkeysym", @@ -2104,7 +2143,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -2397,7 +2436,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -2477,6 +2516,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2746,7 +2794,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3103,7 +3151,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3215,7 +3263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3741,6 +3789,7 @@ version = "2.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" dependencies = [ + "chrono", "paste", "rustc_version", "specta-macros", @@ -4026,7 +4075,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -4077,7 +4126,7 @@ dependencies = [ "sha2", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "url", "uuid", @@ -4126,7 +4175,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4141,7 +4190,28 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c" +dependencies = [ + "android_logger", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", ] [[package]] @@ -4160,7 +4230,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "windows", "zbus", @@ -4187,7 +4257,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -4217,7 +4287,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "url", @@ -4243,7 +4313,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -4291,7 +4361,7 @@ dependencies = [ "specta-util", "tauri", "tauri-specta-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4337,7 +4407,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml 1.1.3+spec-1.1.0", "url", "urlpattern", @@ -4389,11 +4459,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -4409,9 +4479,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -4439,7 +4509,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -4750,7 +4822,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -5212,7 +5284,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "windows", "windows-core 0.61.2", ] @@ -5707,7 +5779,7 @@ dependencies = [ "log", "os_pipe", "rustix", - "thiserror 2.0.19", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -5754,7 +5826,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3628949..e5ecda5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,15 +1,16 @@ [package] name = "devbox" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "Couteau suisse de développeur : prise de notes, hachage, encodage" +authors = ["Valentin MILLET"] edition = "2024" +# Version des let-chains (`if x && let Some(y) = …`), utilisées dans le domaine. +rust-version = "1.88" +repository = "https://github.com/vmillet-dev/devbox-rs" # Le binaire `export-bindings` en fait un second : sans cette clé, le `cargo run` # nu que lance `tauri dev` ne sait plus lequel démarrer. default-run = "devbox" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. @@ -26,9 +27,13 @@ tauri = { version = "2", features = ["tray-icon"] } # (voir lib.rs). Versions figées par `=` : Specta v2 est en release candidate et # ne garantit pas la compatibilité entre deux rc. tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } -specta = "=2.0.0-rc.25" +# `chrono` : sans elle, un `DateTime` du domaine n'a pas d'impl `Type` et +# rien ne s'exporte. Avec, il traverse en `string`, comme la chaîne ISO d'avant. +specta = { version = "=2.0.0-rc.25", features = ["chrono"] } specta-typescript = "=0.0.12" serde = { version = "1", features = ["derive"] } +# Requis à la compilation par l'expansion de `tauri::generate_context!`, en plus +# des tests qui figent la forme JSON traversant le pont. serde_json = "1.0.151" diesel = { version = "2.3.12", features = ["sqlite"] } diesel_migrations = { version = "2.3.2", features = ["sqlite"] } @@ -37,19 +42,49 @@ diesel_migrations = { version = "2.3.2", features = ["sqlite"] } # build comme sur celle de l'utilisateur. libsqlite3-sys = { version = "0.38.1", features = ["bundled"] } uuid = { version = "1.24.0", features = ["v4"] } -chrono = "0.4.45" +# `serde` : c'est chrono qui sérialise les `DateTime` du domaine, en RFC 3339. +chrono = { version = "0.4.45", features = ["serde"] } tauri-plugin-process = "2" tauri-plugin-opener = "2" tauri-plugin-store = "2" tauri-plugin-clipboard-manager = "2" +# Les pannes non fatales (barre système, raccourci déjà pris) sont signalées ici. +# `eprintln!` ne convenait pas : `windows_subsystem = "windows"` n'alloue pas de +# console en release, donc le diagnostic se perdait là où il sert. +tauri-plugin-log = "2" +log = "0.4" +thiserror = "2.0.20" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" +# Pas de `panic = "abort"` : la détection de mutex empoisonné de `commands::lock` +# repose sur le déroulement de pile, et l'abandon la rendrait inatteignable. +[profile.release] +lto = true +codegen-units = 1 +strip = true + [lints.rust] unsafe_code = "forbid" +rust_2018_idioms = { level = "warn", priority = -1 } +# Signale les `pub` que rien n'atteint depuis l'extérieur du crate. +unreachable_pub = "warn" [lints.clippy] all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +# Ces deux-là réclament une section `# Errors` / `# Panics` sur chaque fonction +# publique qui renvoie un `Result` ou peut paniquer. Le crate n'est publié nulle +# part et sa documentation tient dans `docs/architecture.md` : ce serait des +# dizaines de sections à écrire, et à tenir à jour, pour personne. +missing_errors_doc = "allow" +missing_panics_doc = "allow" +# Même raison : `#[must_use]` protège les appelants d'un crate publié. Ici chaque +# fonction a son unique appelant, dans le même dépôt. +must_use_candidate = "allow" +# `storage::notes::…` ou `domain::note::Note` : la répétition vient du découpage +# par sujet, elle est voulue. +module_name_repetitions = "allow" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..261851f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build(); } diff --git a/src-tauri/clippy.toml b/src-tauri/clippy.toml new file mode 100644 index 0000000..67a7c3d --- /dev/null +++ b/src-tauri/clippy.toml @@ -0,0 +1,3 @@ +# `clippy::doc_markdown` prend ces noms de produit pour des identifiants et réclame +# des backticks. `".."` prolonge la liste par défaut au lieu de la remplacer. +doc-valid-idents = ["SQLite", "DevBox", ".."] diff --git a/src-tauri/src/bin/export-bindings.rs b/src-tauri/src/bin/export-bindings.rs index b1130a6..a7452e0 100644 --- a/src-tauri/src/bin/export-bindings.rs +++ b/src-tauri/src/bin/export-bindings.rs @@ -1,8 +1,5 @@ -//! Régénère `src/app/core/ipc/bindings.ts` sans ouvrir de fenêtre. -//! -//! `npm run tauri dev` le fait déjà au lancement, mais travailler côté front -//! sans démarrer l'application reste courant — et l'attente d'un build Tauri -//! complet pour une signature modifiée ne se justifie pas. +//! Régénère `bindings.ts` sans ouvrir de fenêtre — `npm run tauri dev` le fait +//! déjà au lancement, mais travailler côté front sans démarrer l'app est courant. fn main() { devbox_lib::export_bindings().expect("échec de la génération des bindings TypeScript"); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..d2ca0a0 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,27 @@ +//! Adaptateurs Tauri : verrouiller, déléguer, traduire l'erreur. + +// Une commande reçoit ses arguments désérialisés depuis la charge utile IPC : +// ils arrivent possédés, qu'elle les consomme ou non. +#![allow(clippy::needless_pass_by_value)] + +pub mod error; +pub mod notes; +pub mod spaces; +pub mod tray; + +#[cfg(test)] +mod tests; + +use std::sync::Mutex; + +use error::AppError; + +/// `SqliteConnection` n'est pas `Sync` : deux commandes qui se chevauchent se +/// sérialisent sur ce mutex. +pub type Db = Mutex; + +/// Un mutex empoisonné signifie qu'une commande a paniqué en le tenant : mieux +/// vaut le dire que paniquer à nouveau. +fn lock(db: &Db) -> Result, AppError> { + db.lock().map_err(|_| AppError::storage_unavailable()) +} diff --git a/src-tauri/src/commands/error.rs b/src-tauri/src/commands/error.rs index bafd726..4cdafca 100644 --- a/src-tauri/src/commands/error.rs +++ b/src-tauri/src/commands/error.rs @@ -1,21 +1,19 @@ -//! Erreur traversant le pont Tauri : un **code** stable que le front mappe sur -//! une clé de traduction, ses **paramètres** d'interpolation, et un **détail** -//! technique. Aucun texte destiné à l'utilisateur ne sort d'ici — une `String` -//! mettrait du français dans l'interface anglaise et forcerait le front à -//! analyser de la prose pour réagir à une cause précise. +//! Un **code** stable que le front mappe sur une clé de traduction, ses +//! paramètres d'interpolation, et un détail technique. Aucun texte destiné à +//! l'utilisateur ne sort d'ici : une `String` mettrait du français dans +//! l'interface anglaise, et forcerait le front à analyser de la prose. use std::collections::BTreeMap; use serde::Serialize; use specta::Type; -use crate::domain::rules::ValidationError; +use crate::domain::error::ValidationError; use crate::storage::StorageError; -/// Ajouter une variante la fait apparaître dans le `bindings.ts` généré, ce qui -/// casse la compilation du front tant que `CODE_KEYS` +/// Ajouter une variante casse la compilation du front tant que `CODE_KEYS` /// (`core/errors/error-notifier.service.ts`) et les deux locales n'ont pas leur -/// clé — le miroir n'est plus tenu à la main. +/// clé. /// /// Pas de variante « schéma trop récent » : cette panne avorte le lancement /// pendant la migration, aucune commande ne peut la renvoyer. @@ -25,11 +23,10 @@ pub enum ErrorCode { NoteNotFound, SpaceNotFound, DuplicateSpaceName, - /// Donnée reçue non conforme. Le paramètre `field` nomme le champ en cause. + /// Le paramètre `field` nomme le champ en cause. InvalidInput, /// Mutex empoisonné : une commande a paniqué en tenant la connexion. StorageUnavailable, - /// Panne de lecture ou d'écriture SQLite. Storage, } @@ -39,8 +36,7 @@ pub struct AppError { pub code: ErrorCode, /// Valeurs à interpoler dans le message traduit, ex. `{ "name": "Perso" }`. pub params: BTreeMap, - /// Message technique, affiché en second plan de la bannière. Pas traduit, - /// mais lisible. + /// Affiché en second plan de la bannière. Pas traduit, mais lisible. pub detail: String, } @@ -59,8 +55,6 @@ impl AppError { error } - /// Mutex empoisonné : une commande a paniqué en le tenant, la base peut - /// être incohérente. pub fn storage_unavailable() -> Self { Self::new( ErrorCode::StorageUnavailable, @@ -91,97 +85,16 @@ impl From for AppError { StorageError::SpaceNotFound(id) => { Self::with(ErrorCode::SpaceNotFound, detail, "id", &id) } - // Le nom voyage en paramètre : c'est lui que le front interpole, - // sans jamais relire le message. + // Le nom voyage en paramètre : c'est lui que le front interpole. StorageError::DuplicateSpaceName(name) => { Self::with(ErrorCode::DuplicateSpaceName, detail, "name", &name) } - // Les deux premières sont inatteignables par le pont (voir - // [`ErrorCode`]) ; `Storage` reste honnête et le `detail` porte déjà - // la version en clair. + // Aucune de ces causes ne donne au front autre chose à faire que + // signaler la panne ; le `detail` porte le reste en clair. StorageError::SchemaTooRecent(_) | StorageError::Migration(_) + | StorageError::CorruptRow { .. } | StorageError::Sqlite(_) => Self::new(ErrorCode::Storage, detail), } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_code_serialises_in_camel_case() { - let json = serde_json::to_value(AppError::from(StorageError::NoteNotFound( - "n-1".to_string(), - ))) - .unwrap(); - - // The front discriminates on this exact spelling; serde's default would - // emit "NoteNotFound" and every branch would silently fall through. - assert_eq!(json["code"], "noteNotFound"); - } - - #[test] - fn a_duplicate_space_name_carries_the_name_as_a_parameter() { - let json = serde_json::to_value(AppError::from(StorageError::DuplicateSpaceName( - "Perso".to_string(), - ))) - .unwrap(); - - // The translated message interpolates {{name}}; reading it back out of - // `detail` would mean parsing a French sentence. - assert_eq!(json["code"], "duplicateSpaceName"); - assert_eq!(json["params"]["name"], "Perso"); - } - - #[test] - fn every_error_carries_a_non_empty_detail() { - let errors = [ - StorageError::NoteNotFound("n-1".to_string()), - StorageError::SpaceNotFound("s-1".to_string()), - StorageError::DuplicateSpaceName("Perso".to_string()), - StorageError::SchemaTooRecent("2099-01-01-000000".to_string()), - StorageError::Migration("base verrouillée".to_string()), - ]; - - for error in errors { - assert!(!AppError::from(error).detail.is_empty()); - } - } - - #[test] - fn a_refused_value_names_the_field_at_fault() { - let json = serde_json::to_value(AppError::from(ValidationError::new( - "language", - "« rust » n'est pas un langage reconnu", - ))) - .unwrap(); - - // The front interpolates {{field}}; without it the banner would say - // "a value was rejected" and leave the user guessing which one. - assert_eq!(json["code"], "invalidInput"); - assert_eq!(json["params"]["field"], "language"); - } - - #[test] - fn a_schema_too_recent_degrades_to_storage_rather_than_leaking_a_dead_code() { - // It cannot cross the bridge (it aborts startup), so the front has no - // branch for it — `storage` is the honest code, and the detail carries - // the offending migration in plain text. - let error = AppError::from(StorageError::SchemaTooRecent( - "2099-01-01-000000".to_string(), - )); - - assert!(matches!(error.code, ErrorCode::Storage)); - assert!(error.detail.contains("2099-01-01-000000")); - } - - #[test] - fn params_are_absent_rather_than_null_when_there_is_nothing_to_interpolate() { - let json = serde_json::to_value(AppError::storage_unavailable()).unwrap(); - - assert_eq!(json["code"], "storageUnavailable"); - assert_eq!(json["params"], serde_json::json!({})); - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs deleted file mode 100644 index 0823e99..0000000 --- a/src-tauri/src/commands/mod.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Commandes Tauri exposées au front : verrouiller, déléguer, traduire l'erreur. -//! -//! Une commande qui grossit signale qu'une règle est au mauvais endroit — les -//! décisions vivent dans `crate::domain`, le SQL dans `crate::storage`. -//! -//! Une nouvelle commande doit être `pub`, annotée `#[tauri::command]`, renvoyer -//! `Result<_, AppError>` et être enregistrée dans `generate_handler!` (`lib.rs`). -//! `tray` fait exception au `Result` — voir son module. - -pub mod error; -pub mod notes; -pub mod spaces; -pub mod tray; - -use crate::storage::Db; -use error::AppError; - -/// Verrou sur la connexion partagée. Un mutex empoisonné signifie qu'une -/// commande a paniqué en le tenant : mieux vaut le dire que paniquer à nouveau. -/// -/// Le garde est rendu **mutable** : Diesel prend la connexion en exclusif à -/// chaque requête, y compris en lecture. -fn lock(db: &Db) -> Result, AppError> { - db.lock().map_err(|_| AppError::storage_unavailable()) -} - -#[cfg(test)] -mod tests { - use super::*; - use diesel::prelude::*; - use error::ErrorCode; - use std::sync::Mutex; - - fn in_memory() -> Db { - Mutex::new(diesel::SqliteConnection::establish(":memory:").unwrap()) - } - - #[test] - fn a_healthy_connection_is_handed_over() { - let db = in_memory(); - - assert!(lock(&db).is_ok()); - } - - #[test] - fn a_poisoned_connection_is_reported_instead_of_panicking_again() { - let db = in_memory(); - - // Poison it the way production would: a panic while the guard is held. - // The hook is silenced so a deliberate panic does not look like a crash. - let hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(|_| {})); - let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _guard = db.lock().unwrap(); - panic!("une commande a paniqué en tenant la connexion"); - })); - std::panic::set_hook(hook); - - // `unwrap_err()` would need the guard to be `Debug`, which - // `SqliteConnection` is not; and `unwrap()` in `lock` itself would take - // the whole process down on the next command. - let Err(error) = lock(&db) else { - panic!("un mutex empoisonné doit être signalé, pas rendu"); - }; - - assert!(matches!(error.code, ErrorCode::StorageUnavailable)); - } -} diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index b353b71..e13350b 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -4,15 +4,18 @@ //! renvoient la note **telle que persistée** (c'est elle que l'éditeur adopte) ; //! un identifiant inconnu renvoie `Err`, jamais un `Ok` silencieux ; et dans un //! `NotePatch` un champ absent signifie « ne pas toucher ». +//! +//! Plus rien à valider ici : le langage est un enum, donc une valeur inconnue ne +//! passe plus la désérialisation — et ne compile plus côté front. +use chrono::Utc; use tauri::State; use super::error::AppError; -use super::lock; -use crate::domain::detect; +use super::{Db, lock}; use crate::domain::note::{self, DisplayNote, NoteDraft, NotePatch}; use crate::domain::view::{self, NotesQuery, NotesView}; -use crate::storage::{self, Db}; +use crate::storage; /// Notes filtrées **et** regroupées, prêtes à afficher. Aucune commande ne rend /// la liste brute : elle inviterait à refiltrer côté front. @@ -22,19 +25,14 @@ pub fn query_notes(query: NotesQuery, db: State<'_, Db>) -> Result) -> Result { - let draft = detect::with_detected_language(draft); - - // Validé avant de verrouiller : inutile de prendre le verrou pour un refus. - draft.validate()?; - let mut connection = lock(&db)?; - let note = storage::notes::create(&mut connection, &draft, &storage::now_iso())?; + let note = storage::notes::create(&mut connection, draft, Utc::now())?; Ok(note::decorate_now(note)) } @@ -46,10 +44,8 @@ pub fn update_note( patch: NotePatch, db: State<'_, Db>, ) -> Result { - patch.validate()?; - let mut connection = lock(&db)?; - let note = storage::notes::update(&mut connection, &id, &patch, &storage::now_iso())?; + let note = storage::notes::update(&mut connection, &id, &patch, Utc::now())?; Ok(note::decorate_now(note)) } diff --git a/src-tauri/src/commands/spaces.rs b/src-tauri/src/commands/spaces.rs index de8c20d..ee1c241 100644 --- a/src-tauri/src/commands/spaces.rs +++ b/src-tauri/src/commands/spaces.rs @@ -1,20 +1,16 @@ -//! Commandes « Espaces » : les classeurs dans lesquels les notes sont rangées. +//! Commandes « Espaces ». //! -//! `notes.space_id` porte un `ON DELETE CASCADE`, donc un `DELETE` nu -//! emporterait les notes. [`delete_space`] exige un espace **refuge** et y -//! transfère les notes dans la même transaction — il n'existe volontairement -//! aucune variante sans refuge. -//! -//! À décider : `list_spaces` renvoie une liste vide au premier lancement, et -//! l'application refuse alors de créer une note. Créer un espace initial est un -//! choix produit, pas une contrainte technique. +//! `notes.space_id` porte un `ON DELETE CASCADE`, donc un `DELETE` nu emporterait +//! les notes : [`delete_space`] exige un espace **refuge**, et il n'existe +//! volontairement aucune variante sans. use tauri::State; +use super::Db; use super::error::AppError; use super::lock; use crate::domain::space::{self, Space, SpaceDraft}; -use crate::storage::{self, Db}; +use crate::storage; #[tauri::command] #[specta::specta] @@ -24,12 +20,10 @@ pub fn list_spaces(db: State<'_, Db>) -> Result, AppError> { Ok(storage::spaces::list(&mut connection)?) } -/// Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. #[tauri::command] #[specta::specta] pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result { - // Nom déjà détouré et non vide : le stockage n'a plus qu'à trancher - // l'unicité, la seule chose que lui seul peut voir. + // Détouré et non vide ici ; le stockage ne tranche plus que l'unicité. let name = draft.validated_name()?; let mut connection = lock(&db)?; @@ -37,7 +31,6 @@ pub fn create_space(draft: SpaceDraft, db: State<'_, Db>) -> Result) -> Result { @@ -48,10 +41,7 @@ pub fn rename_space(id: String, draft: SpaceDraft, db: State<'_, Db>) -> Result< Ok(storage::spaces::rename(&mut connection, &id, &name)?) } -/// Supprime un espace après avoir transféré ses notes vers `target_space_id`. -/// -/// Tauri v2 renomme les arguments en camelCase ; c'est `bindings.ts` qui porte -/// désormais le `targetSpaceId` correspondant, sans qu'on ait à l'orthographier. +/// Transfère les notes vers `target_space_id` avant de supprimer. #[tauri::command] #[specta::specta] pub fn delete_space( @@ -60,7 +50,7 @@ pub fn delete_space( db: State<'_, Db>, ) -> Result<(), AppError> { // Un espace son propre refuge verrait ses notes emportées par la cascade - // juste après le transfert : refusé avant même de verrouiller. + // juste après le transfert. space::validate_move_target(&id, &target_space_id)?; let mut connection = lock(&db)?; diff --git a/src-tauri/src/commands/tests.rs b/src-tauri/src/commands/tests.rs new file mode 100644 index 0000000..d1bbbfa --- /dev/null +++ b/src-tauri/src/commands/tests.rs @@ -0,0 +1,40 @@ +use diesel::prelude::*; +use std::sync::Mutex; + +use super::*; +use error::ErrorCode; + +fn in_memory() -> Db { + Mutex::new(diesel::SqliteConnection::establish(":memory:").unwrap()) +} + +#[test] +fn a_healthy_connection_is_handed_over() { + let db = in_memory(); + + assert!(lock(&db).is_ok()); +} + +#[test] +fn a_poisoned_connection_is_reported_instead_of_panicking_again() { + let db = in_memory(); + + // Poison it the way production would: a panic while the guard is held. + // The hook is silenced so a deliberate panic does not look like a crash. + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = db.lock().unwrap(); + panic!("une commande a paniqué en tenant la connexion"); + })); + std::panic::set_hook(hook); + + // `unwrap_err()` would need the guard to be `Debug`, which + // `SqliteConnection` is not; and `unwrap()` in `lock` itself would take + // the whole process down on the next command. + let Err(error) = lock(&db) else { + panic!("un mutex empoisonné doit être signalé, pas rendu"); + }; + + assert!(matches!(error.code, ErrorCode::StorageUnavailable)); +} diff --git a/src-tauri/src/commands/tray.rs b/src-tauri/src/commands/tray.rs index e841d6c..d9fc538 100644 --- a/src-tauri/src/commands/tray.rs +++ b/src-tauri/src/commands/tray.rs @@ -1,14 +1,14 @@ //! Commande « barre système ». //! //! Les libellés traversent le pont **déjà traduits** : la langue de l'interface -//! est une préférence du front, et une table de traductions en Rust en ferait -//! une seconde à tenir en phase. Le natif ne fait que les afficher. +//! est une préférence du front, et une table de traductions en Rust en ferait une +//! seconde à tenir. use serde::Deserialize; use specta::Type; use tauri::AppHandle; -use crate::desktop; +use crate::desktop::tray::{self, MenuLabels}; #[derive(Debug, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -19,12 +19,22 @@ pub struct TrayLabels { pub quit: String, } -/// Ne renvoie **pas** de `Result` : une barre système absente n'est pas une -/// panne que le front puisse traiter, et lui inventer un code d'erreur -/// ajouterait une branche que rien n'afficherait jamais. L'échec est journalisé -/// côté natif, comme pour un raccourci global indisponible. +impl TrayLabels { + fn as_menu_labels(&self) -> MenuLabels<'_> { + MenuLabels { + open: &self.open, + new_note: &self.new_note, + capture: &self.capture, + quit: &self.quit, + } + } +} + +/// Ne renvoie **pas** de `Result` : une barre système absente n'est pas une panne +/// que le front puisse traiter, et lui inventer un code ajouterait une branche +/// que rien n'afficherait. L'échec est journalisé côté natif. #[tauri::command] #[specta::specta] pub fn sync_tray(labels: TrayLabels, app: AppHandle) { - desktop::sync_tray(&app, &labels); + tray::sync(&app, &labels.as_menu_labels()); } diff --git a/src-tauri/src/desktop.rs b/src-tauri/src/desktop.rs index 43a8907..159770d 100644 --- a/src-tauri/src/desktop.rs +++ b/src-tauri/src/desktop.rs @@ -1,37 +1,24 @@ -//! Intégration au bureau : raccourcis globaux et barre système. +//! Barre système et raccourcis globaux : de la glu Tauri, hors des trois couches. +//! `commands` appelle `desktop`, jamais l'inverse. //! -//! Ni règle métier ni persistance — que de la glu Tauri, d'où un module à part -//! plutôt qu'une place dans `commands/ → domain/ ← storage/`, dont il ne fait -//! pas partie. Il ne dépend d'aucun des trois. -//! -//! **Rien de ce qui est visible par l'utilisateur n'est écrit ici.** Les -//! libellés du menu arrivent du front déjà traduits (`core/tray/tray.service.ts`), -//! parce que la langue de l'interface est une préférence du front et qu'une -//! table de traductions en Rust serait une seconde source à tenir. +//! **Rien de ce qui est visible par l'utilisateur n'est écrit ici** : les libellés +//! du menu arrivent du front déjà traduits. -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{AppHandle, Emitter, Manager, Wry}; +pub(crate) mod shortcut; +pub(crate) mod tray; -use crate::commands::tray::TrayLabels; +use tauri::{AppHandle, Emitter, Manager}; -/// Événements poussés vers le front. Miroir de `core/ipc/app-events.service.ts`, -/// où une faute de frappe produirait un abonnement silencieusement inerte. -pub mod events { - pub const CAPTURE: &str = "devbox:capture"; - pub const NEW_NOTE: &str = "devbox:new-note"; +/// Miroir de `core/ipc/app-events.service.ts` : une faute de frappe y produirait +/// un abonnement silencieusement inerte. +pub(crate) mod events { + pub(crate) const CAPTURE: &str = "devbox:capture"; + pub(crate) const NEW_NOTE: &str = "devbox:new-note"; } -pub const TRAY_ID: &str = "devbox"; - -const OPEN_ITEM: &str = "open"; -const NEW_NOTE_ITEM: &str = "new-note"; -const CAPTURE_ITEM: &str = "capture"; -const QUIT_ITEM: &str = "quit"; - -/// Ramène la fenêtre au premier plan. `unminimize` d'abord : une fenêtre réduite -/// que l'on se contente de montrer reste dans la barre des tâches. -pub fn reveal(app: &AppHandle) { +/// `unminimize` d'abord : une fenêtre réduite qu'on se contente de montrer reste +/// dans la barre des tâches. +pub(crate) fn reveal(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); @@ -39,137 +26,9 @@ pub fn reveal(app: &AppHandle) { } } -/// Montre la fenêtre **puis** demande l'action au front. -/// -/// Le natif ne crée jamais la note lui-même : la création reste au front, qui -/// passe par `create_note` comme pour n'importe quelle autre note et profite -/// donc de la détection de langage sans la dupliquer ici. +/// Montre la fenêtre **puis** demande l'action au front : le natif ne crée jamais +/// la note lui-même, ce qui lui évite de dupliquer la détection de langage. fn reveal_and_emit(app: &AppHandle, topic: &str) { reveal(app); let _ = app.emit(topic, ()); } - -/// Vrai quand l'icône de la barre système existe déjà. -pub fn has_tray(app: &AppHandle) -> bool { - app.tray_by_id(TRAY_ID).is_some() -} - -/// Crée l'icône de la barre système, ou remplace seulement son menu si elle -/// existe déjà — c'est ce qui permet à un changement de langue de la retraduire -/// sans la faire clignoter. -/// -/// Best effort : une barre système absente (autre environnement de bureau, -/// session restreinte) est signalée et ignorée. L'application reste entièrement -/// utilisable dans sa fenêtre, et [`has_tray`] empêche la fermeture de la cacher -/// là où plus rien ne saurait la rappeler. -pub fn sync_tray(app: &AppHandle, labels: &TrayLabels) { - let menu = match build_menu(app, labels) { - Ok(menu) => menu, - Err(error) => { - eprintln!("Menu de la barre système indisponible : {error}"); - return; - } - }; - - if let Some(tray) = app.tray_by_id(TRAY_ID) { - if let Err(error) = tray.set_menu(Some(menu)) { - eprintln!("Menu de la barre système non mis à jour : {error}"); - } - return; - } - - if let Err(error) = build_tray(app, menu) { - eprintln!("Barre système indisponible : {error}"); - } -} - -fn build_menu(app: &AppHandle, labels: &TrayLabels) -> tauri::Result> { - let open = MenuItem::with_id(app, OPEN_ITEM, &labels.open, true, None::<&str>)?; - let new_note = MenuItem::with_id(app, NEW_NOTE_ITEM, &labels.new_note, true, None::<&str>)?; - let capture = MenuItem::with_id(app, CAPTURE_ITEM, &labels.capture, true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id(app, QUIT_ITEM, &labels.quit, true, None::<&str>)?; - - Menu::with_items(app, &[&open, &new_note, &capture, &separator, &quit]) -} - -fn build_tray(app: &AppHandle, menu: Menu) -> tauri::Result<()> { - let icon = app - .default_window_icon() - .cloned() - .ok_or_else(|| tauri::Error::UnknownPath)?; - - TrayIconBuilder::with_id(TRAY_ID) - .icon(icon) - .tooltip("DevBox") - // Le clic gauche montre la fenêtre ; le menu reste au clic droit, où - // Windows l'attend. - .show_menu_on_left_click(false) - .menu(&menu) - .on_menu_event(|app, event| match event.id.as_ref() { - OPEN_ITEM => reveal(app), - NEW_NOTE_ITEM => reveal_and_emit(app, events::NEW_NOTE), - CAPTURE_ITEM => reveal_and_emit(app, events::CAPTURE), - // Le seul chemin qui termine réellement le processus : la croix de - // la fenêtre ne fait que la cacher. - QUIT_ITEM => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - reveal(tray.app_handle()); - } - }) - .build(app)?; - - Ok(()) -} - -/// Raccourcis actifs hors de la fenêtre : c'est ce qui remplace le réflexe -/// « j'ouvre le Bloc-notes ». -/// -/// Un enregistrement qui échoue (raccourci déjà pris par une autre application) -/// est signalé mais **non fatal** : DevBox doit démarrer sans son raccourci. -pub fn register_shortcuts(app: &AppHandle) -> tauri::Result<()> { - use tauri_plugin_global_shortcut::{ - Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState, - }; - - const CONTROL_ALT: Modifiers = Modifiers::CONTROL.union(Modifiers::ALT); - let capture = Shortcut::new(Some(CONTROL_ALT), Code::KeyV); - let new_note = Shortcut::new(Some(CONTROL_ALT), Code::KeyN); - - app.plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler(move |app, shortcut, event| { - // Sans ce filtre le relâchement rejouerait l'action. - if event.state() != ShortcutState::Pressed { - return; - } - - let topic = if shortcut == &capture { - events::CAPTURE - } else if shortcut == &new_note { - events::NEW_NOTE - } else { - return; - }; - - reveal_and_emit(app, topic); - }) - .build(), - )?; - - for shortcut in [capture, new_note] { - if let Err(error) = app.global_shortcut().register(shortcut) { - eprintln!("Raccourci global {shortcut:?} indisponible : {error}"); - } - } - - Ok(()) -} diff --git a/src-tauri/src/desktop/shortcut.rs b/src-tauri/src/desktop/shortcut.rs new file mode 100644 index 0000000..7a0e53a --- /dev/null +++ b/src-tauri/src/desktop/shortcut.rs @@ -0,0 +1,44 @@ +//! Raccourcis actifs hors de la fenêtre. + +use tauri::AppHandle; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; + +use super::{events, reveal_and_emit}; + +const CONTROL_ALT: Modifiers = Modifiers::CONTROL.union(Modifiers::ALT); + +/// Un raccourci déjà pris par une autre application est journalisé mais **non +/// fatal** : DevBox doit démarrer sans. +pub(crate) fn register(app: &AppHandle) -> tauri::Result<()> { + let capture = Shortcut::new(Some(CONTROL_ALT), Code::KeyV); + let new_note = Shortcut::new(Some(CONTROL_ALT), Code::KeyN); + + app.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, shortcut, event| { + // Sans ce filtre le relâchement rejouerait l'action. + if event.state() != ShortcutState::Pressed { + return; + } + + let topic = if shortcut == &capture { + events::CAPTURE + } else if shortcut == &new_note { + events::NEW_NOTE + } else { + return; + }; + + reveal_and_emit(app, topic); + }) + .build(), + )?; + + for shortcut in [capture, new_note] { + if let Err(error) = app.global_shortcut().register(shortcut) { + log::warn!("Raccourci global {shortcut:?} indisponible : {error}"); + } + } + + Ok(()) +} diff --git a/src-tauri/src/desktop/tray.rs b/src-tauri/src/desktop/tray.rs new file mode 100644 index 0000000..3678b66 --- /dev/null +++ b/src-tauri/src/desktop/tray.rs @@ -0,0 +1,101 @@ +//! Barre système : l'icône, son menu, et ce que ses entrées déclenchent. + +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Wry}; + +use super::{events, reveal, reveal_and_emit}; + +pub(crate) const TRAY_ID: &str = "devbox"; + +const OPEN_ITEM: &str = "open"; +const NEW_NOTE_ITEM: &str = "new-note"; +const CAPTURE_ITEM: &str = "capture"; +const QUIT_ITEM: &str = "quit"; + +/// Type propre à ce module plutôt que le DTO de `commands::tray` : c'est ce qui +/// garde la dépendance à sens unique. +pub(crate) struct MenuLabels<'a> { + pub open: &'a str, + pub new_note: &'a str, + pub capture: &'a str, + pub quit: &'a str, +} + +pub(crate) fn exists(app: &AppHandle) -> bool { + app.tray_by_id(TRAY_ID).is_some() +} + +/// Crée l'icône, ou remplace seulement son menu si elle existe déjà — un +/// changement de langue la retraduit ainsi sans la faire clignoter. +/// +/// Best effort : une barre système absente est journalisée et ignorée. +/// [`exists`] empêche alors la fermeture de cacher la fenêtre là où plus rien ne +/// saurait la rappeler. +pub(crate) fn sync(app: &AppHandle, labels: &MenuLabels<'_>) { + let menu = match build_menu(app, labels) { + Ok(menu) => menu, + Err(error) => { + log::warn!("Menu de la barre système indisponible : {error}"); + return; + } + }; + + if let Some(tray) = app.tray_by_id(TRAY_ID) { + if let Err(error) = tray.set_menu(Some(menu)) { + log::warn!("Menu de la barre système non mis à jour : {error}"); + } + return; + } + + if let Err(error) = build_tray(app, &menu) { + log::warn!("Barre système indisponible : {error}"); + } +} + +fn build_menu(app: &AppHandle, labels: &MenuLabels<'_>) -> tauri::Result> { + let open = MenuItem::with_id(app, OPEN_ITEM, labels.open, true, None::<&str>)?; + let new_note = MenuItem::with_id(app, NEW_NOTE_ITEM, labels.new_note, true, None::<&str>)?; + let capture = MenuItem::with_id(app, CAPTURE_ITEM, labels.capture, true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, QUIT_ITEM, labels.quit, true, None::<&str>)?; + + Menu::with_items(app, &[&open, &new_note, &capture, &separator, &quit]) +} + +fn build_tray(app: &AppHandle, menu: &Menu) -> tauri::Result<()> { + let icon = app + .default_window_icon() + .cloned() + .ok_or(tauri::Error::UnknownPath)?; + + TrayIconBuilder::with_id(TRAY_ID) + .icon(icon) + .tooltip("DevBox") + // Le clic gauche montre la fenêtre ; le menu reste au clic droit, où + // Windows l'attend. + .show_menu_on_left_click(false) + .menu(menu) + .on_menu_event(|app, event| match event.id.as_ref() { + OPEN_ITEM => reveal(app), + NEW_NOTE_ITEM => reveal_and_emit(app, events::NEW_NOTE), + CAPTURE_ITEM => reveal_and_emit(app, events::CAPTURE), + // Le seul chemin qui termine réellement le processus : la croix de + // la fenêtre ne fait que la cacher. + QUIT_ITEM => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + reveal(tray.app_handle()); + } + }) + .build(app)?; + + Ok(()) +} diff --git a/src-tauri/src/domain.rs b/src-tauri/src/domain.rs new file mode 100644 index 0000000..d7dff65 --- /dev/null +++ b/src-tauri/src/domain.rs @@ -0,0 +1,15 @@ +//! Modèle et règles métier. Ne connaît ni Diesel ni Tauri — `scripts/check-layers.sh` +//! le vérifie, et c'est ce qui rend ces règles éprouvables sans ouvrir de base. + +pub mod error; +pub mod iso8601; +pub mod language; +pub mod note; +pub mod search; +pub mod section; +pub mod space; +pub mod tag; +pub mod view; + +#[cfg(test)] +pub(crate) mod fixtures; diff --git a/src-tauri/src/domain/detect.rs b/src-tauri/src/domain/detect.rs deleted file mode 100644 index 03f99d3..0000000 --- a/src-tauri/src/domain/detect.rs +++ /dev/null @@ -1,559 +0,0 @@ -//! Devine le langage d'un contenu collé. -//! -//! Sans ça toute note naît en `txt` et le rail des formats ne sert qu'à ceux qui -//! pensent à renseigner le sélecteur — un rail vide de valeur. -//! -//! Les heuristiques sont volontairement bon marché et faillibles : le résultat -//! n'est qu'une **valeur initiale**, que l'éditeur laisse changer. Une erreur -//! coûte un clic. -//! -//! Deux points d'entrée, pour le même instant — celui où une note reçoit son -//! premier contenu : [`with_detected_language`] à la création (le raccourci de -//! capture, qui colle et crée d'un coup) et [`language_after_patch`] quand un -//! patch remplit une note encore vide (« + Nouvelle note » puis coller). Passé -//! ce moment, plus rien n'est deviné. - -use super::note::{Note, NoteDraft, NotePatch}; -use super::rules::FALLBACK_LANGUAGE; - -/// Complète le langage d'un brouillon **uniquement** si le front n'en a pas -/// choisi, `txt` faisant office de « rien choisi ». -/// -/// La règle vit ici et non dans la commande : appliquée aussi à `update_note`, -/// elle écraserait à la frappe suivante le langage posé au sélecteur. -pub fn with_detected_language(draft: NoteDraft) -> NoteDraft { - if draft.language != FALLBACK_LANGUAGE { - return draft; - } - - NoteDraft { - language: detect_language(&draft.content).to_string(), - ..draft - } -} - -/// Langage à écrire quand un patch donne à une note son **premier** contenu, -/// `None` quand il n'y a rien à deviner. -/// -/// C'est le geste ordinaire : « + Nouvelle note » crée une note vide, puis on -/// colle dans l'éditeur. La création ne voit alors aucun contenu, et sans cette -/// règle la note resterait en `txt` quoi qu'on y mette. -/// -/// Trois refus, qui sont ce qui empêche la détection de devenir une correction -/// permanente : -/// - le patch pose lui-même un langage — l'utilisateur vient de choisir ; -/// - la note en portait déjà un autre que `txt` — un choix ne se corrige pas ; -/// - la note avait déjà du contenu — elle a son identité, et re-détecter à -/// chaque frappe reprendrait à l'utilisateur le sélecteur qu'on lui offre. -pub fn language_after_patch(before: &Note, patch: &NotePatch) -> Option { - if patch.language.is_some() - || before.language != FALLBACK_LANGUAGE - || !before.content.trim().is_empty() - { - return None; - } - - let content = patch.content.as_ref()?; - if content.trim().is_empty() { - return None; - } - - Some(detect_language(content).to_string()) -} - -/// Renvoie toujours une valeur de [`super::rules::LANGUAGES`]. -/// -/// L'ordre des essais va du signal le plus discriminant au plus vague : une -/// accolade ouvrante est un indice bien plus sûr qu'un `clé: valeur`. -pub fn detect_language(content: &str) -> &'static str { - let trimmed = content.trim(); - if trimmed.is_empty() { - return FALLBACK_LANGUAGE; - } - - if trimmed.starts_with("#!") { - return "sh"; - } - if is_json(trimmed) { - return "json"; - } - - let lower = trimmed.to_lowercase(); - if let Some(markup) = markup_kind(&lower) { - return markup; - } - if is_sql(&lower) { - return "sql"; - } - if is_toml(trimmed) { - return "toml"; - } - if is_python(trimmed) { - return "py"; - } - if is_typescript(trimmed) { - return "ts"; - } - if is_javascript(trimmed) { - return "js"; - } - if is_css(trimmed) { - return "css"; - } - if is_yaml(trimmed) { - return "yml"; - } - if is_markdown(trimmed) { - return "md"; - } - if is_shell(trimmed) { - return "sh"; - } - - FALLBACK_LANGUAGE -} - -/// Lignes désindentées : toutes les règles raisonnent sur le début utile. -fn any_line(content: &str, predicate: impl Fn(&str) -> bool) -> bool { - content.lines().map(str::trim).any(predicate) -} - -fn starts_with_any(line: &str, prefixes: &[&str]) -> bool { - prefixes.iter().any(|prefix| line.starts_with(prefix)) -} - -/// Le guillemet écarte un bloc de code dont l'accolade serait celle d'un corps -/// de fonction ; un tableau se reconnaît à ses crochets seuls. -fn is_json(content: &str) -> bool { - let wrapped = (content.starts_with('{') && content.ends_with('}')) - || (content.starts_with('[') && content.ends_with(']')); - - wrapped && (content.contains('"') || content.starts_with('[')) -} - -fn markup_kind(lower: &str) -> Option<&'static str> { - if !lower.starts_with('<') { - return None; - } - if lower.starts_with("", " bool { - const STATEMENTS: [&str; 8] = [ - "select ", - "insert into", - "update ", - "delete from", - "create table", - "alter table", - "drop table", - "with ", - ]; - - starts_with_any(lower, &STATEMENTS) -} - -/// Une section **et** une affectation : `[…]` seul se confondrait avec un -/// tableau posé sur sa propre ligne dans n'importe quel langage. -fn is_toml(content: &str) -> bool { - any_line(content, is_toml_section) && any_line(content, is_assignment) -} - -fn is_toml_section(line: &str) -> bool { - let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) else { - return false; - }; - let inner = inner.trim_matches(|c| c == '[' || c == ']'); - - !inner.is_empty() && inner.chars().all(is_identifier_char) -} - -fn is_assignment(line: &str) -> bool { - line.split_once('=').is_some_and(|(key, _)| { - let key = key.trim(); - !key.is_empty() && !key.contains(char::is_whitespace) - }) -} - -fn is_identifier_char(c: char) -> bool { - c.is_alphanumeric() || matches!(c, '_' | '-' | '.') -} - -/// `class` demande son deux-points final : sans lui c'est celui de TypeScript. -fn is_python(content: &str) -> bool { - content.contains("__name__") - || any_line(content, |line| { - starts_with_any(line, &["def ", "async def ", "elif "]) - || (line.starts_with("class ") && line.ends_with(':')) - || (line.starts_with("from ") && line.contains(" import ")) - }) -} - -fn is_typescript(content: &str) -> bool { - const ANNOTATIONS: [&str; 4] = [": string", ": number", ": boolean", "implements "]; - const DECLARATIONS: [&str; 4] = ["interface ", "type ", "enum ", "declare "]; - - ANNOTATIONS.iter().any(|marker| content.contains(marker)) - || any_line(content, |line| { - let line = line.strip_prefix("export ").unwrap_or(line); - starts_with_any(line, &DECLARATIONS) - }) -} - -fn is_javascript(content: &str) -> bool { - const KEYWORDS: [&str; 7] = [ - "function ", - "const ", - "let ", - "var ", - "export ", - "import ", - "class ", - ]; - - content.contains("=>") - || content.contains("console.log") - || content.contains("require(") - || any_line(content, |line| starts_with_any(line, &KEYWORDS)) -} - -/// Un sélecteur suivi d'au moins une déclaration : l'accolade seule ne -/// distinguerait pas une feuille de style d'un corps de fonction. -fn is_css(content: &str) -> bool { - if !content.contains('{') || !content.contains('}') { - return false; - } - - // Un `propriété: valeur;` où qu'il soit dans la ligne, et pas seulement en - // fin : une règle compacte (`a { color: #000; }`) tient sur une seule. - let has_declaration = any_line(content, |line| { - line.find(':') - .zip(line.find(';')) - .is_some_and(|(colon, semicolon)| colon < semicolon) - }); - let has_selector = any_line(content, |line| { - line.ends_with('{') - && line.chars().next().is_some_and(|c| { - c.is_ascii_alphabetic() || matches!(c, '.' | '#' | '@' | ':' | '*') - }) - }); - - has_declaration && has_selector -} - -fn is_yaml(content: &str) -> bool { - // Le point-virgule et l'accolade appartiennent aux langages déjà écartés - // plus haut ; les revoir ici signifie qu'on s'est trompé de piste. - if content.contains(';') || content.contains('{') { - return false; - } - - content.starts_with("---") - || any_line(content, |line| line.starts_with("- ") || is_mapping(line)) -} - -/// `clé:` ou `clé: valeur`. L'espace exigé après le deux-points écarte une URL, -/// dont le `http://…` passerait sinon pour une clé. -fn is_mapping(line: &str) -> bool { - let Some((key, value)) = line.split_once(':') else { - return false; - }; - - !key.is_empty() - && key.chars().all(is_identifier_char) - && (value.is_empty() || value.starts_with(' ')) -} - -fn is_markdown(content: &str) -> bool { - const LINE_MARKERS: [&str; 5] = ["# ", "## ", "### ", "* ", "> "]; - - content.contains("```") - || content.contains("](") - || any_line(content, |line| starts_with_any(line, &LINE_MARKERS)) -} - -/// Dernier recours : quelques commandes en tête de ligne. Volontairement pauvre, -/// une liste large attraperait de la prose. -fn is_shell(content: &str) -> bool { - const COMMANDS: [&str; 10] = [ - "echo ", "cd ", "ls ", "cat ", "grep ", "sudo ", "npm ", "git ", "docker ", "curl ", - ]; - - any_line(content, |line| starts_with_any(line, &COMMANDS)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::note::NoteLifecycle; - use crate::domain::rules::LANGUAGES; - - fn draft(language: &str, content: &str) -> NoteDraft { - NoteDraft { - space_id: "s-1".to_string(), - title: String::new(), - language: language.to_string(), - content: content.to_string(), - source: String::new(), - tags: Vec::new(), - pinned: false, - lifecycle: NoteLifecycle::Permanent, - } - } - - #[test] - fn a_draft_with_no_chosen_language_gets_the_detected_one() { - let filled = with_detected_language(draft("txt", "SELECT 1")); - - assert_eq!(filled.language, "sql"); - } - - #[test] - fn a_chosen_language_is_never_overwritten() { - // The editor's select is a decision; re-detecting on every write would - // undo it the moment the content stops looking like that language. - let filled = with_detected_language(draft("md", "SELECT 1")); - - assert_eq!(filled.language, "md"); - } - - #[test] - fn an_empty_draft_stays_plain_text() { - let filled = with_detected_language(draft("txt", "")); - - assert_eq!(filled.language, "txt"); - } - - fn blank_note() -> Note { - Note { - language: "txt".to_string(), - content: String::new(), - ..crate::domain::fixtures::note() - } - } - - fn content_patch(content: &str) -> NotePatch { - NotePatch { - content: Some(content.to_string()), - ..NotePatch::default() - } - } - - #[test] - fn an_empty_note_receiving_its_first_content_gets_a_language() { - // The ordinary gesture: "+ New note" creates an empty note, and the - // paste lands through `update_note`. Without this the note would stay - // `txt` whatever is put in it. - let detected = - language_after_patch(&blank_note(), &content_patch("interface A { id: string }")); - - assert_eq!(detected.as_deref(), Some("ts")); - } - - #[test] - fn a_note_that_already_had_content_keeps_its_language() { - // It has an identity; re-detecting on every keystroke would take the - // select back from the user. - let note = Note { - content: "du texte".to_string(), - ..blank_note() - }; - - assert!(language_after_patch(¬e, &content_patch("SELECT 1")).is_none()); - } - - #[test] - fn a_chosen_language_is_never_corrected_by_a_later_paste() { - let note = Note { - language: "md".to_string(), - ..blank_note() - }; - - assert!(language_after_patch(¬e, &content_patch("SELECT 1")).is_none()); - } - - #[test] - fn a_patch_setting_the_language_itself_is_left_alone() { - // The user just picked from the select, in the same write. - let patch = NotePatch { - language: Some("md".to_string()), - ..content_patch("SELECT 1") - }; - - assert!(language_after_patch(&blank_note(), &patch).is_none()); - } - - #[test] - fn a_patch_carrying_no_content_detects_nothing() { - let patch = NotePatch { - title: Some("Titre".to_string()), - ..NotePatch::default() - }; - - assert!(language_after_patch(&blank_note(), &patch).is_none()); - } - - #[test] - fn clearing_the_content_does_not_detect() { - assert!(language_after_patch(&blank_note(), &content_patch(" ")).is_none()); - } - - #[test] - fn filling_the_language_leaves_the_rest_of_the_draft_alone() { - let filled = with_detected_language(draft("txt", "{\"a\": 1}")); - - assert_eq!(filled.language, "json"); - assert_eq!(filled.content, "{\"a\": 1}"); - assert_eq!(filled.space_id, "s-1"); - } - - #[test] - fn every_detected_language_is_one_the_editor_accepts() { - // A value outside this list would be refused by `validate_language`, so - // detection would turn a paste into a failed creation. - let samples = [ - "", - "#!/bin/bash\necho hi", - "{\"a\": 1}", - "", - "hi", - "SELECT 1", - "[package]\nname = \"x\"", - "def f():\n pass", - "interface A { }", - "const a = 1", - ".a { color: red; }", - "key: value", - "# Title\n\n- item", - "git status", - "juste du texte", - ]; - - for sample in samples { - assert!( - LANGUAGES.contains(&detect_language(sample)), - "sample: {sample}" - ); - } - } - - #[test] - fn an_empty_or_blank_content_stays_plain_text() { - assert_eq!(detect_language(""), "txt"); - assert_eq!(detect_language(" \n "), "txt"); - } - - #[test] - fn prose_stays_plain_text() { - // The common case of a scratch note: it must not be dressed up as code. - assert_eq!( - detect_language("Penser à relancer Marc au sujet du certificat"), - "txt" - ); - } - - #[test] - fn a_json_object_or_array_is_recognised() { - assert_eq!(detect_language("{\n \"id\": 42\n}"), "json"); - assert_eq!(detect_language("[1, 2, 3]"), "json"); - assert_eq!(detect_language(" {\"a\": [1]} "), "json"); - } - - #[test] - fn a_shebang_wins_over_everything_that_follows() { - assert_eq!(detect_language("#!/usr/bin/env python\nimport os"), "sh"); - } - - #[test] - fn markup_splits_between_html_and_xml() { - assert_eq!(detect_language("\n"), "html"); - assert_eq!(detect_language("
hi
"), "html"); - assert_eq!(detect_language("\n"), "xml"); - assert_eq!(detect_language(""), "xml"); - } - - #[test] - fn sql_is_recognised_whatever_its_case() { - assert_eq!(detect_language("SELECT * FROM notes"), "sql"); - assert_eq!(detect_language("select 1"), "sql"); - assert_eq!( - detect_language("CREATE TABLE notes (id TEXT PRIMARY KEY)"), - "sql" - ); - } - - #[test] - fn toml_needs_both_a_section_and_an_assignment() { - assert_eq!(detect_language("[package]\nname = \"devbox\""), "toml"); - // A bare list on its own line is not a section header. - assert_ne!(detect_language("[1, 2]\nx = 3"), "toml"); - } - - #[test] - fn python_is_told_apart_from_typescript_by_its_colon() { - assert_eq!(detect_language("def run():\n return 1"), "py"); - assert_eq!(detect_language("class Note:\n pass"), "py"); - assert_eq!(detect_language("from os import path"), "py"); - // Same keyword, brace instead of colon. - assert_eq!(detect_language("class Note { }"), "js"); - } - - #[test] - fn typescript_wins_over_javascript_on_its_own_markers() { - assert_eq!(detect_language("interface Note { id: string }"), "ts"); - assert_eq!(detect_language("export type Id = string"), "ts"); - assert_eq!(detect_language("const a: number = 1"), "ts"); - // Nothing type-specific: plain JavaScript. - assert_eq!(detect_language("const add = (a, b) => a + b"), "js"); - assert_eq!(detect_language("console.log('hi')"), "js"); - } - - #[test] - fn css_needs_a_selector_and_a_declaration() { - assert_eq!(detect_language(".card {\n color: red;\n}"), "css"); - assert_eq!( - detect_language("@media print {\n a { color: #000; }\n}"), - "css" - ); - } - - #[test] - fn yaml_is_recognised_by_its_mappings_and_lists() { - assert_eq!(detect_language("name: devbox\nversion: 1"), "yml"); - assert_eq!(detect_language("---\nsteps:\n - build"), "yml"); - } - - #[test] - fn a_bare_url_is_not_read_as_a_yaml_mapping() { - // "https://example.com" splits on ':' with a value that has no space; - // without that rule every pasted link would come back as YAML. - assert_ne!(detect_language("https://example.com/a/b"), "yml"); - } - - #[test] - fn markdown_is_recognised_by_its_headings_and_fences() { - assert_eq!(detect_language("# Titre\n\nUn paragraphe."), "md"); - assert_eq!(detect_language("Voir ```code``` ici"), "md"); - assert_eq!(detect_language("Un [lien](https://x.dev)"), "md"); - } - - #[test] - fn shell_commands_are_the_last_resort() { - assert_eq!(detect_language("git status\ngit push"), "sh"); - assert_eq!(detect_language("docker run -p 8080:80 nginx"), "sh"); - } -} diff --git a/src-tauri/src/domain/error.rs b/src-tauri/src/domain/error.rs new file mode 100644 index 0000000..f64f9f1 --- /dev/null +++ b/src-tauri/src/domain/error.rs @@ -0,0 +1,23 @@ +//! Refus d'une donnée reçue. + +use thiserror::Error; + +/// Voyage comme les autres erreurs : un code et un paramètre `field`, jamais une +/// phrase rédigée en français. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Champ « {field} » invalide : {detail}")] +pub struct ValidationError { + /// Champ en cause, tel que le front le nomme. + pub field: &'static str, + /// Détail technique, affiché en second plan. + pub detail: String, +} + +impl ValidationError { + pub fn new(field: &'static str, detail: impl Into) -> Self { + Self { + field, + detail: detail.into(), + } + } +} diff --git a/src-tauri/src/domain/fixtures.rs b/src-tauri/src/domain/fixtures.rs new file mode 100644 index 0000000..6f8e178 --- /dev/null +++ b/src-tauri/src/domain/fixtures.rs @@ -0,0 +1,30 @@ +//! Note de référence partagée par les tests du domaine : un champ ajouté à +//! [`Note`] se déclare ici plutôt que dans chaque module qui en construit une. + +use chrono::{DateTime, Utc}; + +use super::iso8601; +use super::language::Language; +use super::note::{Note, NoteLifecycle}; + +pub(crate) const NOW: &str = "2026-07-25T09:00:00.000Z"; + +pub(crate) fn at(iso: &str) -> DateTime { + iso8601::parse(iso).expect("les tests écrivent des instants valides") +} + +pub(crate) fn note() -> Note { + Note { + id: "n-1".to_string(), + space_id: "s-1".to_string(), + title: "Titre".to_string(), + language: Language::Txt, + content: "Contenu".to_string(), + source: String::new(), + tags: vec!["auth".to_string()], + pinned: false, + created_at: at(NOW), + updated_at: at(NOW), + lifecycle: NoteLifecycle::Permanent, + } +} diff --git a/src-tauri/src/domain/iso8601.rs b/src-tauri/src/domain/iso8601.rs new file mode 100644 index 0000000..bbaf6c4 --- /dev/null +++ b/src-tauri/src/domain/iso8601.rs @@ -0,0 +1,59 @@ +//! Format des instants **stockés**, et sa lecture. +//! +//! ⚠️ Les millisecondes sont toujours écrites, même nulles. `created_at` et +//! `updated_at` sont des colonnes TEXT triées lexicographiquement, et le canevas +//! s'ordonne dessus : `.` (0x2E) précédant `Z` (0x5A), un `09:00:00.500Z` +//! passerait **avant** un `09:00:00Z`. `SecondsFormat::AutoSi`, le défaut de +//! chrono, tombe précisément dans ce piège. +//! +//! Le fil, lui, n'en dépend pas : le front convertit en `Date` à la frontière. + +use chrono::{DateTime, SecondsFormat, Utc}; + +/// `2026-07-25T09:12:00.000Z`. +pub fn format(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +pub fn parse(value: &str) -> Result, chrono::ParseError> { + DateTime::parse_from_rfc3339(value).map(|instant| instant.with_timezone(&Utc)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn milliseconds_are_written_even_when_they_are_zero() { + let instant = parse("2026-07-25T09:00:00Z").unwrap(); + + assert_eq!(format(instant), "2026-07-25T09:00:00.000Z"); + } + + #[test] + fn the_written_form_sorts_the_way_the_column_does() { + // This is the whole point: the canvas orders on a lexicographic TEXT + // comparison, so the shorter form would sort *after* a longer one of the + // same second. + let plain = format(parse("2026-07-25T09:00:00Z").unwrap()); + let with_millis = format(parse("2026-07-25T09:00:00.500Z").unwrap()); + + assert!(plain < with_millis); + } + + #[test] + fn an_offset_instant_is_normalised_to_utc() { + // A column read as UTC but holding a local time would shift the note by + // hours; normalising on the way in is what makes the comparison sound. + let instant = parse("2026-07-25T11:00:00+02:00").unwrap(); + + assert_eq!(format(instant), "2026-07-25T09:00:00.000Z"); + } + + #[test] + fn a_round_trip_keeps_the_instant() { + let written = "2026-07-25T09:12:34.567Z"; + + assert_eq!(format(parse(written).unwrap()), written); + } +} diff --git a/src-tauri/src/domain/language.rs b/src-tauri/src/domain/language.rs new file mode 100644 index 0000000..bd9ac7b --- /dev/null +++ b/src-tauri/src/domain/language.rs @@ -0,0 +1,123 @@ +//! Le langage d'une note : la liste reconnue, et la détection d'un contenu collé. + +pub mod detect; + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Liste **fermée**, et c'est tout l'intérêt : le front la reçoit en union +/// TypeScript générée, donc une valeur inconnue ne compile plus chez lui au lieu +/// d'être refusée à l'exécution. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Type)] +#[serde(rename_all = "lowercase")] +pub enum Language { + Json, + Js, + Ts, + Py, + Sql, + Yml, + Toml, + Xml, + Html, + Css, + Sh, + Md, + /// Défaut, et **signal que le front n'a rien choisi** : c'est lui que la + /// création remplace par une détection. + #[default] + Txt, +} + +impl Language { + pub const ALL: [Self; 13] = [ + Self::Json, + Self::Js, + Self::Ts, + Self::Py, + Self::Sql, + Self::Yml, + Self::Toml, + Self::Xml, + Self::Html, + Self::Css, + Self::Sh, + Self::Md, + Self::Txt, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Js => "js", + Self::Ts => "ts", + Self::Py => "py", + Self::Sql => "sql", + Self::Yml => "yml", + Self::Toml => "toml", + Self::Xml => "xml", + Self::Html => "html", + Self::Css => "css", + Self::Sh => "sh", + Self::Md => "md", + Self::Txt => "txt", + } + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// `notes.language` ne porte aucun `CHECK` (migration 3) : une base écrite par +/// une version plus récente peut contenir un langage inconnu d'ici. +impl FromStr for Language { + type Err = (); + + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|language| language.as_str() == value) + .ok_or(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_round_trips_through_its_stored_form() { + for language in Language::ALL { + assert_eq!(language.as_str().parse(), Ok(language)); + } + } + + #[test] + fn an_unknown_value_is_refused_rather_than_guessed() { + // A database written by a newer binary can hold one; the caller decides + // whether to fall back, and it does so in one place. + assert_eq!("rust".parse::(), Err(())); + assert_eq!("JSON".parse::(), Err(())); + } + + #[test] + fn the_default_is_the_one_detection_replaces() { + assert_eq!(Language::default(), Language::Txt); + } + + #[test] + fn the_serialised_form_matches_the_stored_one() { + // The bindings export this spelling as a TS union; the column holds the + // same string. One vocabulary, two consumers. + for language in Language::ALL { + let json = serde_json::to_value(language).unwrap(); + assert_eq!(json, serde_json::json!(language.as_str())); + } + } +} diff --git a/src-tauri/src/domain/language/detect.rs b/src-tauri/src/domain/language/detect.rs new file mode 100644 index 0000000..1dc1fcc --- /dev/null +++ b/src-tauri/src/domain/language/detect.rs @@ -0,0 +1,275 @@ +//! Devine le langage d'un contenu collé. +//! +//! Heuristiques volontairement bon marché et faillibles : le résultat n'est +//! qu'une **valeur initiale**, que l'éditeur laisse changer. Une erreur coûte un +//! clic. Elles ne jouent qu'au moment où une note reçoit son premier contenu — +//! [`for_draft`] à la création, [`after_patch`] au premier collage. Passé ce +//! moment, plus rien n'est deviné. + +use super::Language; +use crate::domain::note::{Note, NoteDraft, NotePatch}; + +/// Celui du front s'il en a choisi un, sinon une détection — `txt` faisant +/// office de « rien choisi ». +pub fn for_draft(draft: &NoteDraft) -> Language { + if draft.language == Language::default() { + from_content(&draft.content) + } else { + draft.language + } +} + +/// Le geste ordinaire : « + Nouvelle note » crée une note vide, puis on colle. +/// La création ne voyant aucun contenu, sans ça la note resterait en `txt`. +/// +/// Les trois refus sont ce qui empêche la détection de devenir une correction +/// permanente : un langage posé au sélecteur, un langage déjà autre que `txt`, +/// ou une note qui avait déjà du contenu. +pub fn after_patch(before: &Note, patch: &NotePatch) -> Option { + if patch.language.is_some() + || before.language != Language::default() + || !before.content.trim().is_empty() + { + return None; + } + + let content = patch.content.as_ref()?; + if content.trim().is_empty() { + return None; + } + + Some(from_content(content)) +} + +/// L'ordre des essais va du signal le plus discriminant au plus vague. +pub fn from_content(content: &str) -> Language { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Language::default(); + } + + if trimmed.starts_with("#!") { + return Language::Sh; + } + if is_json(trimmed) { + return Language::Json; + } + + let lower = trimmed.to_lowercase(); + if let Some(markup) = markup_kind(&lower) { + return markup; + } + if is_sql(&lower) { + return Language::Sql; + } + if is_toml(trimmed) { + return Language::Toml; + } + if is_python(trimmed) { + return Language::Py; + } + if is_typescript(trimmed) { + return Language::Ts; + } + if is_javascript(trimmed) { + return Language::Js; + } + if is_css(trimmed) { + return Language::Css; + } + if is_yaml(trimmed) { + return Language::Yml; + } + if is_markdown(trimmed) { + return Language::Md; + } + if is_shell(trimmed) { + return Language::Sh; + } + + Language::default() +} + +fn any_line(content: &str, predicate: impl Fn(&str) -> bool) -> bool { + content.lines().map(str::trim).any(predicate) +} + +fn starts_with_any(line: &str, prefixes: &[&str]) -> bool { + prefixes.iter().any(|prefix| line.starts_with(prefix)) +} + +/// Le guillemet écarte un bloc de code dont l'accolade serait celle d'un corps +/// de fonction. +fn is_json(content: &str) -> bool { + let wrapped = (content.starts_with('{') && content.ends_with('}')) + || (content.starts_with('[') && content.ends_with(']')); + + wrapped && (content.contains('"') || content.starts_with('[')) +} + +fn markup_kind(lower: &str) -> Option { + const HTML_TAGS: [&str; 8] = [ + "", " bool { + const STATEMENTS: [&str; 8] = [ + "select ", + "insert into", + "update ", + "delete from", + "create table", + "alter table", + "drop table", + "with ", + ]; + + starts_with_any(lower, &STATEMENTS) +} + +/// Une section **et** une affectation : `[…]` seul se confondrait avec un +/// tableau sur sa propre ligne dans n'importe quel langage. +fn is_toml(content: &str) -> bool { + any_line(content, is_toml_section) && any_line(content, is_assignment) +} + +fn is_toml_section(line: &str) -> bool { + let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) else { + return false; + }; + let inner = inner.trim_matches(|c| c == '[' || c == ']'); + + !inner.is_empty() && inner.chars().all(is_identifier_char) +} + +fn is_assignment(line: &str) -> bool { + line.split_once('=').is_some_and(|(key, _)| { + let key = key.trim(); + !key.is_empty() && !key.contains(char::is_whitespace) + }) +} + +fn is_identifier_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '_' | '-' | '.') +} + +/// `class` demande son deux-points final : sans lui c'est celui de TypeScript. +fn is_python(content: &str) -> bool { + content.contains("__name__") + || any_line(content, |line| { + starts_with_any(line, &["def ", "async def ", "elif "]) + || (line.starts_with("class ") && line.ends_with(':')) + || (line.starts_with("from ") && line.contains(" import ")) + }) +} + +fn is_typescript(content: &str) -> bool { + const ANNOTATIONS: [&str; 4] = [": string", ": number", ": boolean", "implements "]; + const DECLARATIONS: [&str; 4] = ["interface ", "type ", "enum ", "declare "]; + + ANNOTATIONS.iter().any(|marker| content.contains(marker)) + || any_line(content, |line| { + let line = line.strip_prefix("export ").unwrap_or(line); + starts_with_any(line, &DECLARATIONS) + }) +} + +fn is_javascript(content: &str) -> bool { + const KEYWORDS: [&str; 7] = [ + "function ", + "const ", + "let ", + "var ", + "export ", + "import ", + "class ", + ]; + + content.contains("=>") + || content.contains("console.log") + || content.contains("require(") + || any_line(content, |line| starts_with_any(line, &KEYWORDS)) +} + +/// Un sélecteur **et** une déclaration : l'accolade seule ne distinguerait pas +/// une feuille de style d'un corps de fonction. +fn is_css(content: &str) -> bool { + if !content.contains('{') || !content.contains('}') { + return false; + } + + // Où qu'il soit dans la ligne : une règle compacte tient sur une seule. + let has_declaration = any_line(content, |line| { + line.find(':') + .zip(line.find(';')) + .is_some_and(|(colon, semicolon)| colon < semicolon) + }); + let has_selector = any_line(content, |line| { + line.ends_with('{') + && line.chars().next().is_some_and(|c| { + c.is_ascii_alphabetic() || matches!(c, '.' | '#' | '@' | ':' | '*') + }) + }); + + has_declaration && has_selector +} + +fn is_yaml(content: &str) -> bool { + // Ils appartiennent aux langages déjà écartés plus haut : les revoir ici + // signifie qu'on s'est trompé de piste. + if content.contains(';') || content.contains('{') { + return false; + } + + content.starts_with("---") + || any_line(content, |line| line.starts_with("- ") || is_mapping(line)) +} + +/// L'espace exigé après le deux-points écarte une URL, dont le `http://…` +/// passerait sinon pour une clé. +fn is_mapping(line: &str) -> bool { + let Some((key, value)) = line.split_once(':') else { + return false; + }; + + !key.is_empty() + && key.chars().all(is_identifier_char) + && (value.is_empty() || value.starts_with(' ')) +} + +fn is_markdown(content: &str) -> bool { + const LINE_MARKERS: [&str; 5] = ["# ", "## ", "### ", "* ", "> "]; + + content.contains("```") + || content.contains("](") + || any_line(content, |line| starts_with_any(line, &LINE_MARKERS)) +} + +/// Volontairement pauvre : une liste large attraperait de la prose. +fn is_shell(content: &str) -> bool { + const COMMANDS: [&str; 10] = [ + "echo ", "cd ", "ls ", "cat ", "grep ", "sudo ", "npm ", "git ", "docker ", "curl ", + ]; + + any_line(content, |line| starts_with_any(line, &COMMANDS)) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/domain/language/detect/tests.rs b/src-tauri/src/domain/language/detect/tests.rs new file mode 100644 index 0000000..811da17 --- /dev/null +++ b/src-tauri/src/domain/language/detect/tests.rs @@ -0,0 +1,251 @@ +use super::*; +use crate::domain::language::Language; +use crate::domain::note::NoteLifecycle; + +fn draft(language: Language, content: &str) -> NoteDraft { + NoteDraft { + space_id: "s-1".to_string(), + title: String::new(), + language, + content: content.to_string(), + source: String::new(), + tags: Vec::new(), + pinned: false, + lifecycle: NoteLifecycle::Permanent, + } +} + +#[test] +fn a_draft_with_no_chosen_language_gets_the_detected_one() { + assert_eq!(for_draft(&draft(Language::Txt, "SELECT 1")), Language::Sql); +} + +#[test] +fn a_chosen_language_is_never_overwritten() { + // The editor's select is a decision; re-detecting on every write would + // undo it the moment the content stops looking like that language. + assert_eq!(for_draft(&draft(Language::Md, "SELECT 1")), Language::Md); +} + +#[test] +fn an_empty_draft_stays_plain_text() { + assert_eq!(for_draft(&draft(Language::Txt, "")), Language::Txt); +} + +fn blank_note() -> Note { + Note { + language: Language::Txt, + content: String::new(), + ..crate::domain::fixtures::note() + } +} + +fn content_patch(content: &str) -> NotePatch { + NotePatch { + content: Some(content.to_string()), + ..NotePatch::default() + } +} + +#[test] +fn an_empty_note_receiving_its_first_content_gets_a_language() { + // The ordinary gesture: "+ New note" creates an empty note, and the + // paste lands through `update_note`. Without this the note would stay + // `txt` whatever is put in it. + let detected = after_patch(&blank_note(), &content_patch("interface A { id: string }")); + + assert_eq!(detected, Some(Language::Ts)); +} + +#[test] +fn a_note_that_already_had_content_keeps_its_language() { + // It has an identity; re-detecting on every keystroke would take the + // select back from the user. + let note = Note { + content: "du texte".to_string(), + ..blank_note() + }; + + assert!(after_patch(¬e, &content_patch("SELECT 1")).is_none()); +} + +#[test] +fn a_chosen_language_is_never_corrected_by_a_later_paste() { + let note = Note { + language: Language::Md, + ..blank_note() + }; + + assert!(after_patch(¬e, &content_patch("SELECT 1")).is_none()); +} + +#[test] +fn a_patch_setting_the_language_itself_is_left_alone() { + // The user just picked from the select, in the same write. + let patch = NotePatch { + language: Some(Language::Md), + ..content_patch("SELECT 1") + }; + + assert!(after_patch(&blank_note(), &patch).is_none()); +} + +#[test] +fn a_patch_carrying_no_content_detects_nothing() { + let patch = NotePatch { + title: Some("Titre".to_string()), + ..NotePatch::default() + }; + + assert!(after_patch(&blank_note(), &patch).is_none()); +} + +#[test] +fn clearing_the_content_does_not_detect() { + assert!(after_patch(&blank_note(), &content_patch(" ")).is_none()); +} + +#[test] +fn every_detected_language_is_one_the_editor_accepts() { + // A value outside this list would be refused by `language::validate`, so + // detection would turn a paste into a failed creation. + let samples = [ + "", + "#!/bin/bash\necho hi", + "{\"a\": 1}", + "
", + "hi", + "SELECT 1", + "[package]\nname = \"x\"", + "def f():\n pass", + "interface A { }", + "const a = 1", + ".a { color: red; }", + "key: value", + "# Title\n\n- item", + "git status", + "juste du texte", + ]; + + for sample in samples { + assert!( + Language::ALL.contains(&from_content(sample)), + "sample: {sample}" + ); + } +} + +#[test] +fn an_empty_or_blank_content_stays_plain_text() { + assert_eq!(from_content(""), Language::Txt); + assert_eq!(from_content(" \n "), Language::Txt); +} + +#[test] +fn prose_stays_plain_text() { + // The common case of a scratch note: it must not be dressed up as code. + assert_eq!( + from_content("Penser à relancer Marc au sujet du certificat"), + Language::Txt + ); +} + +#[test] +fn a_json_object_or_array_is_recognised() { + assert_eq!(from_content("{\n \"id\": 42\n}"), Language::Json); + assert_eq!(from_content("[1, 2, 3]"), Language::Json); + assert_eq!(from_content(" {\"a\": [1]} "), Language::Json); +} + +#[test] +fn a_shebang_wins_over_everything_that_follows() { + assert_eq!( + from_content("#!/usr/bin/env python\nimport os"), + Language::Sh + ); +} + +#[test] +fn markup_splits_between_html_and_xml() { + assert_eq!( + from_content("\n"), + Language::Html + ); + assert_eq!(from_content("
hi
"), Language::Html); + assert_eq!( + from_content("\n"), + Language::Xml + ); + assert_eq!(from_content(""), Language::Xml); +} + +#[test] +fn sql_is_recognised_whatever_its_case() { + assert_eq!(from_content("SELECT * FROM notes"), Language::Sql); + assert_eq!(from_content("select 1"), Language::Sql); + assert_eq!( + from_content("CREATE TABLE notes (id TEXT PRIMARY KEY)"), + Language::Sql + ); +} + +#[test] +fn toml_needs_both_a_section_and_an_assignment() { + assert_eq!(from_content("[package]\nname = \"devbox\""), Language::Toml); + // A bare list on its own line is not a section header. + assert_ne!(from_content("[1, 2]\nx = 3"), Language::Toml); +} + +#[test] +fn python_is_told_apart_from_typescript_by_its_colon() { + assert_eq!(from_content("def run():\n return 1"), Language::Py); + assert_eq!(from_content("class Note:\n pass"), Language::Py); + assert_eq!(from_content("from os import path"), Language::Py); + // Same keyword, brace instead of colon. + assert_eq!(from_content("class Note { }"), Language::Js); +} + +#[test] +fn typescript_wins_over_javascript_on_its_own_markers() { + assert_eq!(from_content("interface Note { id: string }"), Language::Ts); + assert_eq!(from_content("export type Id = string"), Language::Ts); + assert_eq!(from_content("const a: number = 1"), Language::Ts); + // Nothing type-specific: plain JavaScript. + assert_eq!(from_content("const add = (a, b) => a + b"), Language::Js); + assert_eq!(from_content("console.log('hi')"), Language::Js); +} + +#[test] +fn css_needs_a_selector_and_a_declaration() { + assert_eq!(from_content(".card {\n color: red;\n}"), Language::Css); + assert_eq!( + from_content("@media print {\n a { color: #000; }\n}"), + Language::Css + ); +} + +#[test] +fn yaml_is_recognised_by_its_mappings_and_lists() { + assert_eq!(from_content("name: devbox\nversion: 1"), Language::Yml); + assert_eq!(from_content("---\nsteps:\n - build"), Language::Yml); +} + +#[test] +fn a_bare_url_is_not_read_as_a_yaml_mapping() { + // "https://example.com" splits on ':' with a value that has no space; + // without that rule every pasted link would come back as YAML. + assert_ne!(from_content("https://example.com/a/b"), Language::Yml); +} + +#[test] +fn markdown_is_recognised_by_its_headings_and_fences() { + assert_eq!(from_content("# Titre\n\nUn paragraphe."), Language::Md); + assert_eq!(from_content("Voir ```code``` ici"), Language::Md); + assert_eq!(from_content("Un [lien](https://x.dev)"), Language::Md); +} + +#[test] +fn shell_commands_are_the_last_resort() { + assert_eq!(from_content("git status\ngit push"), Language::Sh); + assert_eq!(from_content("docker run -p 8080:80 nginx"), Language::Sh); +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs deleted file mode 100644 index 669211b..0000000 --- a/src-tauri/src/domain/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Modèle et règles métier : `commands/ ──► domain/ ◄── storage/`. -//! -//! Ne connaît ni SQLite ni Tauri — d'où des règles éprouvables sans ouvrir de -//! connexion ni lancer l'application. -//! -//! Les attributs serde sont portés par le modèle plutôt que par une seconde -//! famille de DTO. Le contrat traversant le pont est figé par des tests de -//! sérialisation : c'est ce que le compilateur ne peut pas vérifier et qui -//! casse silencieusement le front. - -pub mod detect; -pub mod note; -pub mod rules; -pub mod sections; -pub mod space; -pub mod view; - -/// Reference note shared by the domain tests, so a field added to `Note` is -/// declared once instead of in every test module that builds one. -#[cfg(test)] -pub(crate) mod fixtures { - use super::note::{Note, NoteLifecycle}; - - pub(crate) fn note() -> Note { - Note { - id: "n-1".to_string(), - space_id: "s-1".to_string(), - title: "Titre".to_string(), - language: "txt".to_string(), - content: "Contenu".to_string(), - source: String::new(), - tags: vec!["auth".to_string()], - pinned: false, - created_at: "2026-07-25T09:00:00.000Z".to_string(), - updated_at: "2026-07-25T09:00:00.000Z".to_string(), - lifecycle: NoteLifecycle::Permanent, - } - } -} diff --git a/src-tauri/src/domain/note.rs b/src-tauri/src/domain/note.rs index 824e2b6..181c172 100644 --- a/src-tauri/src/domain/note.rs +++ b/src-tauri/src/domain/note.rs @@ -1,63 +1,51 @@ -//! La note : ce qui est persisté ([`Note`]) et ce qui est affiché -//! ([`DisplayNote`]). La persistance ignore tout du second. +//! La note : ce qui est persisté ([`Note`]) et ce qui est affiché ([`DisplayNote`]). //! -//! ⚠️ **Contrat de sérialisation.** Deux attributs sont indispensables, sinon le -//! front reçoit des données qu'il ne sait pas relire : -//! - `rename_all = "camelCase"`, sans quoi serde émet `space_id` là où le DTO -//! TypeScript attend `spaceId` ; -//! - `tag = "kind"` sur les enums à données, dont la représentation serde par -//! défaut est `{"Expires":{…}}` alors que le front discrimine sur `kind`. -//! -//! Les dates transitent en chaîne ISO 8601 UTC (JSON n'a pas de type date). -//! -//! Les variantes de pied de carte portent une **date**, pas un libellé : « il y -//! a 4 min » doit vieillir tout seul à l'écran. Le formatage reste au front. +//! ⚠️ `rename_all` et `tag = "kind"` sont load-bearing — sans eux serde émet +//! `space_id` et `{"Expires":{…}}`, que le front ne sait pas relire. +//! `tests/ipc_contract.rs` les fige. -use chrono::{DateTime, FixedOffset, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; -use super::rules::{self, ValidationError}; +use super::language::{Language, detect}; +use super::tag; #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Note { pub id: String, - /// Espace de rangement. C'est la requête qui filtre dessus ; le stockage - /// refuse de créer une note dans un espace inconnu. pub space_id: String, - /// Peut être vide : une note fraîchement créée n'a pas encore de titre, - /// l'interface affiche un libellé traduit à la place. + /// Peut être vide : l'interface affiche alors un libellé traduit. pub title: String, - /// "json" | "js" | "py" | "sql" | "yml" | "txt". - pub language: String, + pub language: Language, pub content: String, - /// Chemin de contexte libre, ex. "API Gateway / Auth". Peut être vide. + /// Fil d'Ariane libre, ex. "API Gateway / Auth". Peut être vide. pub source: String, pub tags: Vec, pub pinned: bool, - /// ISO 8601, ex. "2026-07-25T09:12:00.000Z". - pub created_at: String, - pub updated_at: String, + pub created_at: DateTime, + pub updated_at: DateTime, pub lifecycle: NoteLifecycle, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum NoteLifecycle { - /// Note permanente. Permanent, - /// Note éphémère : elle est « à trier » jusqu'à cette date. - Expires { at: String }, + /// « À trier » jusqu'à cette date. + Expires { + at: DateTime, + }, } -/// Création : ni identifiant ni horodatages — c'est la persistance qui les attribue. +/// Ni identifiant ni horodatages : la persistance les attribue. #[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NoteDraft { pub space_id: String, pub title: String, - pub language: String, + pub language: Language, pub content: String, pub source: String, pub tags: Vec, @@ -65,22 +53,20 @@ pub struct NoteDraft { pub lifecycle: NoteLifecycle, } -/// Modification partielle : un champ à `None` reste **inchangé** en base. +/// Un champ à `None` reste **inchangé** en base. /// -/// `#[specta(optional)]` génère `title?: string | null` plutôt que -/// `title: string | null` : le front **omet** les clés qu'il ne touche pas, et -/// un type qui les exigerait toutes l'obligerait à envoyer des `null`, c'est-à-dire -/// à écraser ce qu'il voulait laisser intact. +/// `#[specta(optional)]` rend les clés omissibles côté TypeScript. Sans lui le +/// front devrait envoyer des `null` pour les champs qu'il ne touche pas — donc +/// écraser ce qu'il voulait laisser intact. #[derive(Debug, Clone, Default, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotePatch { - /// Renseigné uniquement lors d'un déplacement de note vers un autre espace. #[specta(optional)] pub space_id: Option, #[specta(optional)] pub title: Option, #[specta(optional)] - pub language: Option, + pub language: Option, #[specta(optional)] pub content: Option, #[specta(optional)] @@ -94,43 +80,88 @@ pub struct NotePatch { } impl NoteDraft { - pub fn validate(&self) -> Result<(), ValidationError> { - rules::validate_language(&self.language) + /// Langage deviné si le front n'en a pas choisi, tags normalisés : ces deux + /// règles vivent ici, pas dans le SQL. + pub fn into_note(self, id: String, now: DateTime) -> Note { + let language = detect::for_draft(&self); + let tags = tag::normalize(&self.tags); + + Note { + id, + space_id: self.space_id, + title: self.title, + language, + content: self.content, + source: self.source, + tags, + pinned: self.pinned, + created_at: now, + updated_at: now, + lifecycle: self.lifecycle, + } } } impl NotePatch { - /// Un champ absent n'est pas validé : il ne sera pas écrit. - pub fn validate(&self) -> Result<(), ValidationError> { - match &self.language { - Some(language) => rules::validate_language(language), - None => Ok(()), + /// Applique les champs renseignés et rafraîchit `updated_at` ; un `None` + /// laisse la note intacte. + /// + /// La détection de langage est décidée sur l'état **d'avant** patch : c'est + /// lui qui dit si la note reçoit là son premier contenu. + /// + /// ⚠️ Ne vérifie pas que `space_id` existe — seule la persistance peut le + /// voir, et elle le fait avant d'appeler. + pub fn apply(&self, note: &mut Note, now: DateTime) { + let detected = detect::after_patch(note, self); + + if let Some(space_id) = &self.space_id { + note.space_id.clone_from(space_id); + } + if let Some(title) = &self.title { + note.title.clone_from(title); + } + if let Some(language) = self.language { + note.language = language; + } + if let Some(content) = &self.content { + note.content.clone_from(content); + } + if let Some(language) = detected { + note.language = language; + } + if let Some(source) = &self.source { + note.source.clone_from(source); } + if let Some(pinned) = self.pinned { + note.pinned = pinned; + } + if let Some(tags) = &self.tags { + note.tags = tag::normalize(tags); + } + if let Some(lifecycle) = &self.lifecycle { + note.lifecycle = lifecycle.clone(); + } + + note.updated_at = now; } } -/// Au-delà de ce délai, une note éphémère n'est plus « bientôt à trier ». -/// Seuil **unique** : le front en tenait un second, pour un libellé qui ne -/// promet qu'une définition de « bientôt ». -const EXPIRING_SOON_DAYS: i64 = 3; - -const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; +/// Seuil **unique** de « bientôt à trier » : le front en tenait un second. +const EXPIRING_SOON: TimeDelta = TimeDelta::days(3); -/// Contenu du pied d'une carte — la **décision**, pas le rendu. +/// Pied d'une carte : la **décision**, pas le rendu. Les variantes datées +/// portent une date et non un libellé — « il y a 4 min » doit vieillir tout seul +/// à l'écran, donc le formatage reste au front. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum NoteFooter { - /// Note épinglée portant un contexte : elle est là pour durer, savoir d'où - /// elle vient est plus utile que son âge. Source { value: String }, - /// Échéance d'une note éphémère. - Expiry { at: String }, - /// Âge de la dernière modification — le cas ordinaire. - Age { at: String }, + Expiry { at: DateTime }, + Age { at: DateTime }, } -/// Note augmentée de ce que l'affichage doit savoir. `flatten` aplatit la note -/// dans l'objet JSON : le front n'a qu'un seul type de note. +/// `flatten` aplatit la note dans le même objet JSON : le front n'a qu'un seul +/// type de note. #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct DisplayNote { @@ -140,8 +171,7 @@ pub struct DisplayNote { pub expiring_soon: bool, } -/// Lire `display_note.id` plutôt que `display_note.note.id` évite de faire -/// remonter l'emballage chez l'appelant. +/// Pour lire `note.id` plutôt que `note.note.id`. impl std::ops::Deref for DisplayNote { type Target = Note; @@ -150,7 +180,7 @@ impl std::ops::Deref for DisplayNote { } } -pub fn decorate(note: Note, now: &DateTime) -> DisplayNote { +pub fn decorate(note: Note, now: DateTime) -> DisplayNote { DisplayNote { footer: footer_of(¬e), expiring_soon: expires_soon(¬e, now), @@ -158,19 +188,18 @@ pub fn decorate(note: Note, now: &DateTime) -> DisplayNote { } } -/// Pour une note qu'on vient d'écrire : `create_note` et `update_note` ne -/// reçoivent pas d'instant de référence du front, contrairement à une requête. +/// Contrairement à une requête, la création et la mise à jour ne reçoivent pas +/// d'instant de référence du front. pub fn decorate_now(note: Note) -> DisplayNote { - decorate(note, &Utc::now().fixed_offset()) + decorate(note, Utc::now()) } fn footer_of(note: &Note) -> NoteFooter { - if let NoteLifecycle::Expires { at } = ¬e.lifecycle { - return NoteFooter::Expiry { at: at.clone() }; + if let NoteLifecycle::Expires { at } = note.lifecycle { + return NoteFooter::Expiry { at }; } - // `source` est un fil d'Ariane ("API Gateway / Auth") : son premier segment - // situe la note sans déborder de la carte. + // Le premier segment situe la note sans déborder de la carte. if note.pinned && let Some(root) = note .source @@ -184,225 +213,19 @@ fn footer_of(note: &Note) -> NoteFooter { } NoteFooter::Age { - at: note.updated_at.clone(), + at: note.updated_at, } } -/// Une échéance illisible ne rend pas la note urgente : ce serait un faux signal -/// permanent. -fn expires_soon(note: &Note, now: &DateTime) -> bool { - let NoteLifecycle::Expires { at } = ¬e.lifecycle else { - return false; - }; - let Ok(deadline) = DateTime::parse_from_rfc3339(at) else { +fn expires_soon(note: &Note, now: DateTime) -> bool { + let NoteLifecycle::Expires { at } = note.lifecycle else { return false; }; - // En millisecondes et non en jours entiers : à 3 jours et 1 heure, un + // Une durée, pas un nombre de jours entiers : à 3 jours et 1 heure, un // arrondi basculerait la note en alerte un jour trop tôt. - deadline.signed_duration_since(*now).num_milliseconds() <= EXPIRING_SOON_DAYS * MS_PER_DAY + at.signed_duration_since(now) <= EXPIRING_SOON } -/// Ces tests figent la **forme JSON** traversant le pont : la seule chose que le -/// compilateur ne peut pas contrôler et qui casse silencieusement le front. #[cfg(test)] -mod tests { - use super::*; - use crate::domain::fixtures::note as sample; - - const NOW: &str = "2026-07-25T09:00:00.000Z"; - - fn now() -> DateTime { - DateTime::parse_from_rfc3339(NOW).unwrap() - } - - fn expiring(at: &str) -> Note { - Note { - lifecycle: NoteLifecycle::Expires { at: at.to_string() }, - ..sample() - } - } - - #[test] - fn a_note_serialises_with_camel_case_keys() { - let json = serde_json::to_value(sample()).unwrap(); - - // The TypeScript DTO reads `spaceId` / `createdAt` / `updatedAt`; serde's - // default would emit the snake_case field names and the front would see - // `undefined` where it expects an ISO date. - assert!(json.get("spaceId").is_some()); - assert!(json.get("createdAt").is_some()); - assert!(json.get("updatedAt").is_some()); - assert!(json.get("space_id").is_none()); - assert!(json.get("created_at").is_none()); - } - - #[test] - fn a_permanent_lifecycle_serialises_as_a_tagged_object() { - let json = serde_json::to_value(sample()).unwrap(); - - // Not serde's default `"Permanent"` — the front discriminates on `kind`. - assert_eq!( - json["lifecycle"], - serde_json::json!({ "kind": "permanent" }) - ); - } - - #[test] - fn an_expiring_lifecycle_serialises_flat_with_its_date() { - let note = Note { - lifecycle: NoteLifecycle::Expires { - at: "2026-08-01T00:00:00.000Z".to_string(), - }, - ..sample() - }; - - let json = serde_json::to_value(note).unwrap(); - - // Not `{"Expires":{"at":…}}`, which the TS discriminated union rejects. - assert_eq!( - json["lifecycle"], - serde_json::json!({ "kind": "expires", "at": "2026-08-01T00:00:00.000Z" }) - ); - } - - #[test] - fn a_patch_omitting_a_field_deserialises_to_none() { - // `toNotePatchDto` copies field by field precisely so that untouched - // fields are absent rather than null; absent must mean "leave alone". - let patch: NotePatch = serde_json::from_value(serde_json::json!({ - "title": "Nouveau titre" - })) - .unwrap(); - - assert_eq!(patch.title.as_deref(), Some("Nouveau titre")); - assert!(patch.content.is_none()); - assert!(patch.tags.is_none()); - assert!(patch.lifecycle.is_none()); - } - - #[test] - fn a_draft_is_read_from_the_camel_case_payload_the_front_sends() { - let draft: NoteDraft = serde_json::from_value(serde_json::json!({ - "spaceId": "s-1", - "title": "", - "language": "sql", - "content": "SELECT 1", - "source": "", - "tags": ["db"], - "pinned": true, - "lifecycle": { "kind": "expires", "at": "2026-08-01T00:00:00.000Z" } - })) - .unwrap(); - - assert_eq!(draft.space_id, "s-1"); - assert!(draft.pinned); - assert!(matches!(draft.lifecycle, NoteLifecycle::Expires { .. })); - } - - #[test] - fn an_ordinary_note_shows_the_age_of_its_last_change() { - let footer = footer_of(&sample()); - - assert_eq!( - footer, - NoteFooter::Age { - at: "2026-07-25T09:00:00.000Z".to_string() - } - ); - } - - #[test] - fn a_pinned_note_shows_the_first_segment_of_its_context() { - let note = Note { - pinned: true, - source: "API Gateway / Auth / Tokens".to_string(), - ..sample() - }; - - assert_eq!( - footer_of(¬e), - NoteFooter::Source { - value: "API Gateway".to_string() - } - ); - } - - #[test] - fn a_pinned_note_without_context_falls_back_to_its_age() { - let note = Note { - pinned: true, - source: String::new(), - ..sample() - }; - - assert!(matches!(footer_of(¬e), NoteFooter::Age { .. })); - } - - #[test] - fn an_expiring_note_shows_its_deadline_even_when_pinned() { - let note = Note { - pinned: true, - source: "API Gateway".to_string(), - ..expiring("2026-08-01T00:00:00.000Z") - }; - - // The deadline is the more urgent thing to know; the context can wait. - assert!(matches!(footer_of(¬e), NoteFooter::Expiry { .. })); - } - - #[test] - fn a_permanent_note_never_counts_as_expiring_soon() { - assert!(!expires_soon(&sample(), &now())); - } - - #[test] - fn the_threshold_is_measured_in_fractions_of_a_day() { - // Three days and one hour is not "soon"; rounding to whole days would - // raise the alert a day early. - assert!(!expires_soon(&expiring("2026-07-28T10:00:00.000Z"), &now())); - assert!(expires_soon(&expiring("2026-07-28T08:00:00.000Z"), &now())); - } - - #[test] - fn an_already_expired_note_counts_as_expiring_soon() { - assert!(expires_soon(&expiring("2026-07-01T00:00:00.000Z"), &now())); - } - - #[test] - fn an_unreadable_deadline_does_not_raise_a_permanent_alert() { - assert!(!expires_soon(&expiring("pas une date"), &now())); - } - - #[test] - fn a_decorated_note_serialises_flat_with_its_footer() { - let json = serde_json::to_value(decorate(sample(), &now())).unwrap(); - - // The front reads one object: the note's own fields sit alongside the - // display ones, not nested under a `note` key. - assert_eq!(json["id"], "n-1"); - assert_eq!(json["spaceId"], "s-1"); - assert_eq!(json["expiringSoon"], false); - assert_eq!( - json["footer"], - serde_json::json!({ "kind": "age", "at": "2026-07-25T09:00:00.000Z" }) - ); - assert!(json.get("note").is_none()); - } - - #[test] - fn a_source_footer_serialises_with_the_kind_the_front_discriminates_on() { - let note = Note { - pinned: true, - source: "API Gateway / Auth".to_string(), - ..sample() - }; - - let json = serde_json::to_value(decorate(note, &now())).unwrap(); - - assert_eq!( - json["footer"], - serde_json::json!({ "kind": "source", "value": "API Gateway" }) - ); - } -} +mod tests; diff --git a/src-tauri/src/domain/note/tests.rs b/src-tauri/src/domain/note/tests.rs new file mode 100644 index 0000000..6fdd331 --- /dev/null +++ b/src-tauri/src/domain/note/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::domain::fixtures::note as sample; +use crate::domain::language::Language; + +const NOW: &str = "2026-07-25T09:00:00.000Z"; + +fn at(iso: &str) -> DateTime { + crate::domain::iso8601::parse(iso).unwrap() +} + +fn now() -> DateTime { + at(NOW) +} + +fn expiring(at_iso: &str) -> Note { + Note { + lifecycle: NoteLifecycle::Expires { at: at(at_iso) }, + ..sample() + } +} + +fn draft(language: Language, content: &str) -> NoteDraft { + NoteDraft { + space_id: "s-1".to_string(), + title: "Titre".to_string(), + language, + content: content.to_string(), + source: "API Gateway".to_string(), + tags: vec![" #Urgent ".to_string(), "urgent".to_string()], + pinned: true, + lifecycle: NoteLifecycle::Permanent, + } +} + +#[test] +fn a_draft_becomes_a_note_carrying_the_id_and_the_instant_it_was_given() { + let note = draft(Language::Md, "du texte").into_note("n-7".to_string(), now()); + + assert_eq!(note.id, "n-7"); + assert_eq!(note.created_at, now()); + assert_eq!(note.updated_at, now()); +} + +#[test] +fn turning_a_draft_into_a_note_detects_the_language_and_normalises_the_tags() { + // Both rules used to run in `storage`, where they needed an open database + // to be exercised at all. + let note = draft(Language::Txt, "{\"a\": 1}").into_note("n-7".to_string(), now()); + + assert_eq!(note.language, Language::Json); + assert_eq!(note.tags, ["Urgent"]); +} + +#[test] +fn turning_a_draft_into_a_note_leaves_everything_else_alone() { + let note = draft(Language::Md, "SELECT 1").into_note("n-7".to_string(), now()); + + // A chosen language is a decision; only the rest travels verbatim. + assert_eq!(note.language, Language::Md); + assert_eq!(note.content, "SELECT 1"); + assert_eq!(note.space_id, "s-1"); + assert_eq!(note.source, "API Gateway"); + assert!(note.pinned); +} + +#[test] +fn a_patch_only_touches_the_fields_it_carries() { + let mut note = sample(); + let patch = NotePatch { + title: Some("Nouveau".to_string()), + ..NotePatch::default() + }; + + patch.apply(&mut note, at("2026-07-25T10:00:00.000Z")); + + assert_eq!(note.title, "Nouveau"); + assert_eq!(note.content, "Contenu"); + assert_eq!(note.tags, ["auth"]); + assert_eq!(note.updated_at, at("2026-07-25T10:00:00.000Z")); + // `created_at` is the one stamp nothing may move. + assert_eq!(note.created_at, now()); +} + +#[test] +fn a_patch_normalises_the_tags_it_replaces() { + let mut note = sample(); + let patch = NotePatch { + tags: Some(vec![ + " #Ops ".to_string(), + "OPS".to_string(), + " ".to_string(), + ]), + ..NotePatch::default() + }; + + patch.apply(&mut note, now()); + + assert_eq!(note.tags, ["Ops"]); +} + +#[test] +fn a_patch_filling_an_empty_note_detects_its_language() { + // Decided on the state *before* the patch: that is what says whether the + // note is receiving its first content. + let mut note = Note { + language: Language::Txt, + content: String::new(), + ..sample() + }; + let patch = NotePatch { + content: Some("SELECT 1".to_string()), + ..NotePatch::default() + }; + + patch.apply(&mut note, now()); + + assert_eq!(note.language, Language::Sql); +} + +#[test] +fn an_ordinary_note_shows_the_age_of_its_last_change() { + let footer = footer_of(&sample()); + + assert_eq!(footer, NoteFooter::Age { at: at(NOW) }); +} + +#[test] +fn a_pinned_note_shows_the_first_segment_of_its_context() { + let note = Note { + pinned: true, + source: "API Gateway / Auth / Tokens".to_string(), + ..sample() + }; + + assert_eq!( + footer_of(¬e), + NoteFooter::Source { + value: "API Gateway".to_string() + } + ); +} + +#[test] +fn a_pinned_note_without_context_falls_back_to_its_age() { + let note = Note { + pinned: true, + source: String::new(), + ..sample() + }; + + assert!(matches!(footer_of(¬e), NoteFooter::Age { .. })); +} + +#[test] +fn an_expiring_note_shows_its_deadline_even_when_pinned() { + let note = Note { + pinned: true, + source: "API Gateway".to_string(), + ..expiring("2026-08-01T00:00:00.000Z") + }; + + // The deadline is the more urgent thing to know; the context can wait. + assert!(matches!(footer_of(¬e), NoteFooter::Expiry { .. })); +} + +#[test] +fn a_permanent_note_never_counts_as_expiring_soon() { + assert!(!expires_soon(&sample(), now())); +} + +#[test] +fn the_threshold_is_measured_in_fractions_of_a_day() { + // Three days and one hour is not "soon"; rounding to whole days would + // raise the alert a day early. + assert!(!expires_soon(&expiring("2026-07-28T10:00:00.000Z"), now())); + assert!(expires_soon(&expiring("2026-07-28T08:00:00.000Z"), now())); +} + +#[test] +fn an_already_expired_note_counts_as_expiring_soon() { + assert!(expires_soon(&expiring("2026-07-01T00:00:00.000Z"), now())); +} diff --git a/src-tauri/src/domain/rules.rs b/src-tauri/src/domain/rules.rs deleted file mode 100644 index 16d907c..0000000 --- a/src-tauri/src/domain/rules.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Règles de validation et de correspondance, et le type d'erreur qu'elles -//! renvoient. -//! -//! Le back valide ce que le front contrôle déjà : une règle tenue par un seul -//! formulaire n'est pas tenue. Un appel direct au pont ou un front d'une autre -//! version suffisent à écrire une donnée que plus rien ne saura interpréter. - -use std::fmt; - -use super::note::Note; - -/// Donnée reçue non conforme. Voyage comme les autres erreurs : un code et un -/// paramètre `field`, jamais une phrase rédigée en français. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ValidationError { - /// Champ en cause, tel que le front le nomme. - pub field: &'static str, - /// Détail technique, affiché en second plan. - pub detail: String, -} - -impl ValidationError { - pub fn new(field: &'static str, detail: impl Into) -> Self { - Self { - field, - detail: detail.into(), - } - } -} - -impl fmt::Display for ValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Champ « {} » invalide : {}", self.field, self.detail) - } -} - -impl std::error::Error for ValidationError {} - -/// Langages reconnus pour la coloration. **La** référence : le front en a un -/// miroir (`core/models/language.model.ts`) pour peupler son sélecteur et -/// dégrader vers `txt`, mais c'est ici que l'écriture est refusée. -pub const LANGUAGES: [&str; 13] = [ - "json", "js", "ts", "py", "sql", "yml", "toml", "xml", "html", "css", "sh", "md", "txt", -]; - -/// Langage par défaut, et **signal que le front n'a rien choisi** : c'est lui -/// que `create_note` remplace par une détection. Miroir de `FALLBACK_LANGUAGE` -/// côté front (`core/language/language.model.ts`). -pub const FALLBACK_LANGUAGE: &str = "txt"; - -pub fn validate_language(language: &str) -> Result<(), ValidationError> { - if LANGUAGES.contains(&language) { - return Ok(()); - } - - Err(ValidationError::new( - "language", - format!("« {language} » n'est pas un langage reconnu"), - )) -} - -/// Nettoie les tags : espaces, `#` de tête, vides et doublons. -/// -/// Règle unique — le front envoie ce que l'utilisateur a tapé, la persistance -/// écrit ce que cette fonction renvoie, et la requête y fait passer les tags -/// sélectionnés, sinon un `#urgent` saisi ne retrouverait pas `urgent` stocké. -/// -/// La déduplication est insensible à la casse et garde la première graphie ; -/// `COLLATE NOCASE` (migration 2) prolonge la règle à tout le corpus. -pub fn normalize_tags(tags: &[String]) -> Vec { - let mut seen: Vec = Vec::new(); - let mut normalized: Vec = Vec::new(); - - for tag in tags { - let cleaned = tag.trim().trim_start_matches('#').trim(); - if cleaned.is_empty() { - continue; - } - - let folded = cleaned.to_lowercase(); - if seen.contains(&folded) { - continue; - } - - seen.push(folded); - normalized.push(cleaned.to_string()); - } - - normalized -} - -/// Correspondance d'une note avec un texte cherché. `needle` est attendu **déjà -/// replié en minuscules et détouré**. -/// -/// Le repliage est fait en Rust et non en SQL : le `LOWER()` de SQLite ne traite -/// que l'ASCII sans ICU, donc `Étape` ne correspondrait pas à `étape`. D'où une -/// recherche qui ne descend pas dans le `WHERE`, contrairement aux filtres -/// grossiers, qui eux y restent indexés. -pub fn matches(note: &Note, needle: &str) -> bool { - note.title.to_lowercase().contains(needle) - || note - .tags - .iter() - .any(|tag| tag.to_lowercase().contains(needle)) - || note.content.to_lowercase().contains(needle) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::fixtures::note as sample; - - #[test] - fn every_known_language_is_accepted() { - for language in LANGUAGES { - assert!(validate_language(language).is_ok()); - } - } - - #[test] - fn an_unknown_language_is_refused_with_its_field() { - let error = validate_language("rust").unwrap_err(); - - assert_eq!(error.field, "language"); - assert!(error.detail.contains("rust")); - } - - #[test] - fn the_comparison_is_exact_rather_than_case_insensitive() { - // The front sends the tag from a fixed list, never free text; accepting - // "JSON" would put a second spelling of one language into the database. - assert!(validate_language("JSON").is_err()); - assert!(validate_language("").is_err()); - } - - fn normalized(tags: &[&str]) -> Vec { - normalize_tags(&tags.iter().map(|tag| tag.to_string()).collect::>()) - } - - #[test] - fn padding_and_blanks_are_dropped() { - assert_eq!( - normalized(&[" urgent ", "", " ", "later"]), - ["urgent", "later"] - ); - } - - #[test] - fn a_duplicate_keeps_its_first_spelling() { - assert_eq!(normalized(&["Urgent", "urgent", "URGENT"]), ["Urgent"]); - } - - #[test] - fn only_leading_hashes_are_stripped() { - assert_eq!(normalized(&["##c++", "a#b"]), ["c++", "a#b"]); - } - - #[test] - fn a_tag_reduced_to_nothing_is_dropped_rather_than_stored_empty() { - // " # " trims to "#", then to "" — storing that would put a blank facet - // in the rail that selects every note carrying it. - assert!(normalized(&[" # ", "#"]).is_empty()); - } - - #[test] - fn tag_case_folding_reaches_beyond_ascii() { - // SQLite's NOCASE would not collapse these; `to_lowercase` is Unicode. - assert_eq!(normalized(&["Étape", "étape"]), ["Étape"]); - } - - #[test] - fn the_title_the_tags_and_the_content_are_all_searched() { - let note = Note { - title: "Déploiement".to_string(), - content: "kubectl apply".to_string(), - tags: vec!["ops".to_string()], - ..sample() - }; - - assert!(matches(¬e, "déploi")); - assert!(matches(¬e, "kubectl")); - assert!(matches(¬e, "ops")); - assert!(!matches(¬e, "terraform")); - } - - #[test] - fn search_case_folding_reaches_beyond_ascii() { - let note = Note { - title: "Étape suivante".to_string(), - ..sample() - }; - - // SQLite's LOWER() leaves É alone without ICU, so this match is exactly - // what moving the comparison into Rust buys. - assert!(matches(¬e, "étape")); - } -} diff --git a/src-tauri/src/domain/search.rs b/src-tauri/src/domain/search.rs new file mode 100644 index 0000000..954da09 --- /dev/null +++ b/src-tauri/src/domain/search.rs @@ -0,0 +1,51 @@ +//! Correspondance d'une note avec un texte cherché. + +use super::note::Note; + +/// `needle` est attendu **déjà replié en minuscules et détouré**. +/// +/// ⚠️ Le repliage est en Rust et non en SQL : sans ICU, le `LOWER()` de SQLite ne +/// traite que l'ASCII, donc `Étape` ne correspondrait pas à `étape`. D'où une +/// recherche qui ne descend pas dans le `WHERE`, contrairement aux filtres +/// grossiers, qui eux y restent indexés. +pub fn matches(note: &Note, needle: &str) -> bool { + note.title.to_lowercase().contains(needle) + || note + .tags + .iter() + .any(|tag| tag.to_lowercase().contains(needle)) + || note.content.to_lowercase().contains(needle) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::fixtures::note as sample; + + #[test] + fn the_title_the_tags_and_the_content_are_all_searched() { + let note = Note { + title: "Déploiement".to_string(), + content: "kubectl apply".to_string(), + tags: vec!["ops".to_string()], + ..sample() + }; + + assert!(matches(¬e, "déploi")); + assert!(matches(¬e, "kubectl")); + assert!(matches(¬e, "ops")); + assert!(!matches(¬e, "terraform")); + } + + #[test] + fn search_case_folding_reaches_beyond_ascii() { + let note = Note { + title: "Étape suivante".to_string(), + ..sample() + }; + + // SQLite's LOWER() leaves É alone without ICU, so this match is exactly + // what moving the comparison into Rust buys. + assert!(matches(¬e, "étape")); + } +} diff --git a/src-tauri/src/domain/section.rs b/src-tauri/src/domain/section.rs new file mode 100644 index 0000000..4339689 --- /dev/null +++ b/src-tauri/src/domain/section.rs @@ -0,0 +1,120 @@ +//! Regroupement des notes en sections d'affichage. +//! +//! ⚠️ **L'exhaustivité est une garantie.** Hors épinglées, chaque note tombe dans +//! exactement une section : une note sans section serait introuvable dans +//! l'interface, recherche comprise. + +use chrono::{DateTime, Datelike, FixedOffset, TimeDelta, Utc}; + +use super::note::{self, DisplayNote, Note}; +use super::view::{NoteSection, NoteSectionKey}; + +const A_WEEK: TimeDelta = TimeDelta::days(7); + +/// Amplitude des fuseaux réels : UTC−12 à UTC+14. +const MAX_TZ_OFFSET_MINUTES: u32 = 14 * 60; + +/// ⚠️ Le signe s'inverse : JavaScript compte les minutes à **ajouter** à l'heure +/// locale pour obtenir UTC (−120 pour UTC+2), chrono attend le décalage à l'est. +/// +/// La borne est vérifiée **avant** la multiplication : la valeur vient du pont, +/// et `-i32::MIN` comme `i32::MAX * 60` déborderaient — d'où `unsigned_abs`. +pub fn offset_from_minutes(tz_offset_minutes: i32) -> FixedOffset { + let utc = FixedOffset::east_opt(0).expect("UTC est un décalage valide"); + + if tz_offset_minutes.unsigned_abs() > MAX_TZ_OFFSET_MINUTES { + return utc; + } + + FixedOffset::east_opt(-tz_offset_minutes * 60).unwrap_or(utc) +} + +fn is_same_local_day(a: &DateTime, b: &DateTime) -> bool { + a.year() == b.year() && a.month() == b.month() && a.day() == b.day() +} + +fn is_within(date: &DateTime, now: &DateTime, window: TimeDelta) -> bool { + let elapsed = now.signed_duration_since(*date); + elapsed >= TimeDelta::zero() && elapsed <= window +} + +fn section( + key: NoteSectionKey, + notes: Vec, + show_create_ghost: bool, + now: DateTime, +) -> NoteSection { + let notes: Vec = notes + .into_iter() + .map(|note| note::decorate(note, now)) + .collect(); + + NoteSection { + has_expiring_notes: notes.iter().any(|note| note.expiring_soon), + key, + notes, + show_create_ghost, + } +} + +/// Répartis par date, les résultats se diluent et semblent absents quand tout +/// tombe en bas de page. +fn results(notes: Vec, now: DateTime) -> Vec { + vec![section(NoteSectionKey::Results, notes, false, now)] +} + +/// `is_filtering` bascule en liste plate. L'ordre reçu est conservé dans chaque +/// section : c'est celui du tri SQL, et il fait autorité. +pub fn build( + notes: Vec, + is_filtering: bool, + now: DateTime, + offset: FixedOffset, +) -> Vec { + let local_now = now.with_timezone(&offset); + if is_filtering { + return results(notes, now); + } + + let mut pinned = Vec::new(); + let mut today = Vec::new(); + let mut this_week = Vec::new(); + let mut older = Vec::new(); + + for note in notes { + if note.pinned { + pinned.push(note); + continue; + } + + let created = note.created_at.with_timezone(&offset); + if is_same_local_day(&created, &local_now) { + today.push(note); + } else if is_within(&created, &local_now, A_WEEK) { + this_week.push(note); + } else { + older.push(note); + } + } + + let mut sections = Vec::new(); + + if !pinned.is_empty() { + sections.push(section(NoteSectionKey::Pinned, pinned, false, now)); + } + if !today.is_empty() { + sections.push(section(NoteSectionKey::Today, today, false, now)); + } + + // Toujours présente : c'est elle qui héberge la carte « coller ou créer ». + sections.push(section(NoteSectionKey::Week, this_week, true, now)); + + if !older.is_empty() { + sections.push(section(NoteSectionKey::Older, older, false, now)); + } + + sections +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/domain/section/tests.rs b/src-tauri/src/domain/section/tests.rs new file mode 100644 index 0000000..505079a --- /dev/null +++ b/src-tauri/src/domain/section/tests.rs @@ -0,0 +1,273 @@ +use super::*; +use crate::domain::iso8601; +use crate::domain::language::Language; + +fn at(iso: &str) -> DateTime { + iso8601::parse(iso).expect("les tests écrivent des instants valides") +} +use crate::domain::note::NoteLifecycle; + +/// 25 July 2026, 09:00 UTC. +const NOW: &str = "2026-07-25T09:00:00.000Z"; + +fn utc() -> FixedOffset { + FixedOffset::east_opt(0).unwrap() +} + +fn now_at(_offset: FixedOffset) -> DateTime { + at(NOW) +} + +#[test] +fn a_real_offset_keeps_its_sign_inverted() { + // JavaScript reports -120 for UTC+2 and 300 for UTC-5. + assert_eq!(offset_from_minutes(-120).local_minus_utc(), 2 * 3600); + assert_eq!(offset_from_minutes(300).local_minus_utc(), -5 * 3600); + assert_eq!(offset_from_minutes(0).local_minus_utc(), 0); +} + +#[test] +fn an_absurd_offset_falls_back_to_utc_without_overflowing() { + // The value crosses the IPC bridge unvalidated. Negating i32::MIN or + // multiplying i32::MAX by 60 overflows, which panics in debug while the + // connection mutex is held — poisoning it for the rest of the process. + for absurd in [i32::MIN, i32::MAX, -100_000, 100_000, 841, -841] { + assert_eq!(offset_from_minutes(absurd).local_minus_utc(), 0); + } +} + +fn note(id: &str, created_at: &str) -> Note { + Note { + id: id.to_string(), + space_id: "s-1".to_string(), + title: String::new(), + language: Language::Txt, + content: String::new(), + source: String::new(), + tags: Vec::new(), + pinned: false, + created_at: at(created_at), + updated_at: at(created_at), + lifecycle: NoteLifecycle::Permanent, + } +} + +fn keys(sections: &[NoteSection]) -> Vec { + sections.iter().map(|section| section.key).collect() +} + +fn ids_in(sections: &[NoteSection], key: NoteSectionKey) -> Vec { + sections + .iter() + .filter(|section| section.key == key) + .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) + .collect() +} + +#[test] +fn every_unpinned_note_lands_in_exactly_one_section() { + let offset = utc(); + let notes = vec![ + note("today", "2026-07-25T08:00:00.000Z"), + note("week", "2026-07-21T08:00:00.000Z"), + note("older", "2020-01-01T08:00:00.000Z"), + ]; + + let sections = build(notes, false, now_at(offset), offset); + + // A note in no section would be unreachable in the UI, search included. + let placed: Vec = sections + .iter() + .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) + .collect(); + assert_eq!(placed.len(), 3); + assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["today"]); + assert_eq!(ids_in(§ions, NoteSectionKey::Week), ["week"]); + assert_eq!(ids_in(§ions, NoteSectionKey::Older), ["older"]); +} + +#[test] +fn the_week_section_is_present_even_when_empty() { + let offset = utc(); + + let sections = build(Vec::new(), false, now_at(offset), offset); + + // It hosts the "paste or create" ghost card, so it cannot be dropped. + assert_eq!(keys(§ions), [NoteSectionKey::Week]); + assert!(sections[0].show_create_ghost); +} + +#[test] +fn only_the_week_section_carries_the_create_ghost() { + let offset = utc(); + let notes = vec![ + note("today", "2026-07-25T08:00:00.000Z"), + note("older", "2020-01-01T08:00:00.000Z"), + ]; + + let sections = build(notes, false, now_at(offset), offset); + + let with_ghost: Vec = sections + .iter() + .filter(|section| section.show_create_ghost) + .map(|section| section.key) + .collect(); + assert_eq!(with_ghost, [NoteSectionKey::Week]); +} + +#[test] +fn pinned_notes_leave_the_chronological_sections() { + let offset = utc(); + let mut pinned = note("pinned", "2026-07-25T08:00:00.000Z"); + pinned.pinned = true; + + let sections = build(vec![pinned], false, now_at(offset), offset); + + assert_eq!(ids_in(§ions, NoteSectionKey::Pinned), ["pinned"]); + assert!(ids_in(§ions, NoteSectionKey::Today).is_empty()); +} + +#[test] +fn empty_sections_other_than_week_are_omitted() { + let offset = utc(); + + let sections = build( + vec![note("today", "2026-07-25T08:00:00.000Z")], + false, + now_at(offset), + offset, + ); + + assert_eq!( + keys(§ions), + [NoteSectionKey::Today, NoteSectionKey::Week] + ); +} + +#[test] +fn filtering_collapses_everything_into_a_single_flat_section() { + let offset = utc(); + let mut pinned = note("pinned", "2026-07-25T08:00:00.000Z"); + pinned.pinned = true; + let notes = vec![pinned, note("ancient", "2019-05-05T08:00:00.000Z")]; + + let sections = build(notes, true, now_at(offset), offset); + + // Chronological grouping would bury an old match in a trailing section. + assert_eq!(keys(§ions), [NoteSectionKey::Results]); + assert_eq!(sections[0].notes.len(), 2); + assert!(!sections[0].show_create_ghost); +} + +#[test] +fn a_section_reports_whether_any_of_its_notes_is_due_soon() { + let offset = utc(); + let mut expiring = note("expiring", "2026-07-25T08:00:00.000Z"); + expiring.lifecycle = NoteLifecycle::Expires { + at: at("2026-07-26T00:00:00.000Z"), + }; + + let sections = build( + vec![expiring, note("plain", "2026-07-25T08:00:00.000Z")], + false, + now_at(offset), + offset, + ); + + let today = sections + .iter() + .find(|s| s.key == NoteSectionKey::Today) + .unwrap(); + assert!(today.has_expiring_notes); + let week = sections + .iter() + .find(|s| s.key == NoteSectionKey::Week) + .unwrap(); + assert!(!week.has_expiring_notes); +} + +#[test] +fn a_distant_deadline_does_not_light_up_the_section_hint() { + let offset = utc(); + let mut expiring = note("expiring", "2026-07-25T08:00:00.000Z"); + expiring.lifecycle = NoteLifecycle::Expires { + at: at("2027-01-01T00:00:00.000Z"), + }; + + let sections = build(vec![expiring], false, now_at(offset), offset); + + // The hint reads "to triage soon"; firing it six months ahead would make + // it permanent background noise. + let today = sections + .iter() + .find(|s| s.key == NoteSectionKey::Today) + .unwrap(); + assert!(!today.has_expiring_notes); +} + +#[test] +fn the_day_boundary_follows_the_local_timezone_not_utc() { + // 23:30 in Paris on 25 July is already 21:30 UTC the same day, but a note + // created at 22:10 UTC is 00:10 local on the 26th — tomorrow, not today. + let paris = offset_from_minutes(-120); + let now = at("2026-07-25T21:30:00.000Z"); + + let sections = build( + vec![note("local-today", "2026-07-25T20:00:00.000Z")], + false, + now, + paris, + ); + + assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["local-today"]); +} + +#[test] +fn a_note_created_just_after_local_midnight_is_not_yesterday() { + // Same instant read in UTC would fall on the previous day and land in + // "this week" instead of "today". + let paris = offset_from_minutes(-120); + let now = at("2026-07-26T08:00:00.000Z"); + + let sections = build( + vec![note("after-midnight", "2026-07-25T22:10:00.000Z")], + false, + now, + paris, + ); + + assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["after-midnight"]); +} + +#[test] +fn a_note_created_in_the_future_is_not_swallowed() { + let offset = utc(); + + let sections = build( + vec![note("future", "2030-01-01T00:00:00.000Z")], + false, + now_at(offset), + offset, + ); + + // is_within_days rejects negative elapsed time, so it must still surface + // somewhere rather than vanish between the branches. + assert_eq!(ids_in(§ions, NoteSectionKey::Older), ["future"]); +} + +#[test] +fn the_order_received_is_preserved_inside_a_section() { + let offset = utc(); + let notes = vec![ + note("first", "2026-07-25T08:00:00.000Z"), + note("second", "2026-07-25T07:00:00.000Z"), + ]; + + let sections = build(notes, false, now_at(offset), offset); + + // The SQL ORDER BY decides; this module must not re-sort. + assert_eq!( + ids_in(§ions, NoteSectionKey::Today), + ["first", "second"] + ); +} diff --git a/src-tauri/src/domain/sections.rs b/src-tauri/src/domain/sections.rs deleted file mode 100644 index a2f17e1..0000000 --- a/src-tauri/src/domain/sections.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! Regroupement des notes en sections d'affichage. -//! -//! ⚠️ **L'exhaustivité est une garantie.** Hors notes épinglées, chaque note -//! tombe dans exactement une section parmi `today`, `week` et `older` : une note -//! sans section serait introuvable dans l'interface, recherche comprise. - -use chrono::{DateTime, Datelike, FixedOffset}; - -use super::note::{self, DisplayNote, Note}; -use super::view::{NoteSection, NoteSectionKey}; - -const WEEK_DAYS: i64 = 7; - -/// Amplitude des fuseaux réels : UTC−12 à UTC+14. -const MAX_TZ_OFFSET_MINUTES: u32 = 14 * 60; - -/// Décalage du front (`Date#getTimezoneOffset()`) en `FixedOffset` chrono. -/// -/// ⚠️ Le signe s'inverse : JavaScript compte les minutes à **ajouter** à l'heure -/// locale pour obtenir UTC (−120 pour UTC+2), chrono attend le décalage à l'est. -/// -/// La borne est vérifiée **avant** la multiplication — la valeur vient du pont -/// IPC, et `-i32::MIN` comme `i32::MAX * 60` déborderaient, empoisonnant le -/// mutex pour le reste du processus. D'où aussi `unsigned_abs` plutôt qu'`abs`. -pub fn offset_from_minutes(tz_offset_minutes: i32) -> FixedOffset { - let utc = FixedOffset::east_opt(0).expect("UTC est un décalage valide"); - - if tz_offset_minutes.unsigned_abs() > MAX_TZ_OFFSET_MINUTES { - return utc; - } - - FixedOffset::east_opt(-tz_offset_minutes * 60).unwrap_or(utc) -} - -/// `None` classe la note dans `older`, où elle reste atteignable — une date -/// illisible ne doit pas la faire disparaître. -fn parse(value: &str, offset: &FixedOffset) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|date| date.with_timezone(offset)) -} - -fn is_same_local_day(a: &DateTime, b: &DateTime) -> bool { - a.year() == b.year() && a.month() == b.month() && a.day() == b.day() -} - -fn is_within_days(date: &DateTime, now: &DateTime, days: i64) -> bool { - let elapsed = now.signed_duration_since(*date); - elapsed.num_milliseconds() >= 0 && elapsed.num_days() <= days -} - -/// Seule porte de sortie vers le front : rien ne peut lui échapper. -fn section( - key: NoteSectionKey, - notes: Vec, - show_create_ghost: bool, - now: &DateTime, -) -> NoteSection { - let notes: Vec = notes - .into_iter() - .map(|note| note::decorate(note, now)) - .collect(); - - NoteSection { - has_expiring_notes: notes.iter().any(|note| note.expiring_soon), - key, - notes, - show_create_ghost, - } -} - -/// Vue plate dès qu'une recherche ou une facette est active : répartis par date, -/// les résultats se diluent et semblent absents quand tout tombe en bas de page. -fn results(notes: Vec, now: &DateTime) -> Vec { - vec![section(NoteSectionKey::Results, notes, false, now)] -} - -/// Regroupe les notes en sections. `is_filtering` bascule en liste plate. -/// -/// L'ordre reçu est conservé à l'intérieur de chaque section : c'est celui -/// décidé par le tri SQL, et il fait autorité. -pub fn build( - notes: Vec, - is_filtering: bool, - now: &DateTime, - offset: &FixedOffset, -) -> Vec { - if is_filtering { - return results(notes, now); - } - - let mut pinned = Vec::new(); - let mut today = Vec::new(); - let mut this_week = Vec::new(); - let mut older = Vec::new(); - - for note in notes { - if note.pinned { - pinned.push(note); - continue; - } - - // Le bras `_` couvre « plus vieux qu'une semaine » et « date illisible » : - // dans les deux cas `older`, jamais nulle part. - match parse(¬e.created_at, offset) { - Some(created) if is_same_local_day(&created, now) => today.push(note), - Some(created) if is_within_days(&created, now, WEEK_DAYS) => this_week.push(note), - _ => older.push(note), - } - } - - let mut sections = Vec::new(); - - if !pinned.is_empty() { - sections.push(section(NoteSectionKey::Pinned, pinned, false, now)); - } - if !today.is_empty() { - sections.push(section(NoteSectionKey::Today, today, false, now)); - } - - // Toujours présente : c'est elle qui héberge la carte « coller ou créer ». - sections.push(section(NoteSectionKey::Week, this_week, true, now)); - - if !older.is_empty() { - sections.push(section(NoteSectionKey::Older, older, false, now)); - } - - sections -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::note::NoteLifecycle; - - /// 25 July 2026, 09:00 UTC. - const NOW: &str = "2026-07-25T09:00:00.000Z"; - - fn utc() -> FixedOffset { - FixedOffset::east_opt(0).unwrap() - } - - fn now_at(offset: &FixedOffset) -> DateTime { - DateTime::parse_from_rfc3339(NOW) - .unwrap() - .with_timezone(offset) - } - - #[test] - fn a_real_offset_keeps_its_sign_inverted() { - // JavaScript reports -120 for UTC+2 and 300 for UTC-5. - assert_eq!(offset_from_minutes(-120).local_minus_utc(), 2 * 3600); - assert_eq!(offset_from_minutes(300).local_minus_utc(), -5 * 3600); - assert_eq!(offset_from_minutes(0).local_minus_utc(), 0); - } - - #[test] - fn an_absurd_offset_falls_back_to_utc_without_overflowing() { - // The value crosses the IPC bridge unvalidated. Negating i32::MIN or - // multiplying i32::MAX by 60 overflows, which panics in debug while the - // connection mutex is held — poisoning it for the rest of the process. - for absurd in [i32::MIN, i32::MAX, -100_000, 100_000, 841, -841] { - assert_eq!(offset_from_minutes(absurd).local_minus_utc(), 0); - } - } - - fn note(id: &str, created_at: &str) -> Note { - Note { - id: id.to_string(), - space_id: "s-1".to_string(), - title: String::new(), - language: "txt".to_string(), - content: String::new(), - source: String::new(), - tags: Vec::new(), - pinned: false, - created_at: created_at.to_string(), - updated_at: created_at.to_string(), - lifecycle: NoteLifecycle::Permanent, - } - } - - fn keys(sections: &[NoteSection]) -> Vec { - sections.iter().map(|section| section.key).collect() - } - - fn ids_in(sections: &[NoteSection], key: NoteSectionKey) -> Vec { - sections - .iter() - .filter(|section| section.key == key) - .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) - .collect() - } - - #[test] - fn every_unpinned_note_lands_in_exactly_one_section() { - let offset = utc(); - let notes = vec![ - note("today", "2026-07-25T08:00:00.000Z"), - note("week", "2026-07-21T08:00:00.000Z"), - note("older", "2020-01-01T08:00:00.000Z"), - ]; - - let sections = build(notes, false, &now_at(&offset), &offset); - - // A note in no section would be unreachable in the UI, search included. - let placed: Vec = sections - .iter() - .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) - .collect(); - assert_eq!(placed.len(), 3); - assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["today"]); - assert_eq!(ids_in(§ions, NoteSectionKey::Week), ["week"]); - assert_eq!(ids_in(§ions, NoteSectionKey::Older), ["older"]); - } - - #[test] - fn the_week_section_is_present_even_when_empty() { - let offset = utc(); - - let sections = build(Vec::new(), false, &now_at(&offset), &offset); - - // It hosts the "paste or create" ghost card, so it cannot be dropped. - assert_eq!(keys(§ions), [NoteSectionKey::Week]); - assert!(sections[0].show_create_ghost); - } - - #[test] - fn only_the_week_section_carries_the_create_ghost() { - let offset = utc(); - let notes = vec![ - note("today", "2026-07-25T08:00:00.000Z"), - note("older", "2020-01-01T08:00:00.000Z"), - ]; - - let sections = build(notes, false, &now_at(&offset), &offset); - - let with_ghost: Vec = sections - .iter() - .filter(|section| section.show_create_ghost) - .map(|section| section.key) - .collect(); - assert_eq!(with_ghost, [NoteSectionKey::Week]); - } - - #[test] - fn pinned_notes_leave_the_chronological_sections() { - let offset = utc(); - let mut pinned = note("pinned", "2026-07-25T08:00:00.000Z"); - pinned.pinned = true; - - let sections = build(vec![pinned], false, &now_at(&offset), &offset); - - assert_eq!(ids_in(§ions, NoteSectionKey::Pinned), ["pinned"]); - assert!(ids_in(§ions, NoteSectionKey::Today).is_empty()); - } - - #[test] - fn empty_sections_other_than_week_are_omitted() { - let offset = utc(); - - let sections = build( - vec![note("today", "2026-07-25T08:00:00.000Z")], - false, - &now_at(&offset), - &offset, - ); - - assert_eq!( - keys(§ions), - [NoteSectionKey::Today, NoteSectionKey::Week] - ); - } - - #[test] - fn filtering_collapses_everything_into_a_single_flat_section() { - let offset = utc(); - let mut pinned = note("pinned", "2026-07-25T08:00:00.000Z"); - pinned.pinned = true; - let notes = vec![pinned, note("ancient", "2019-05-05T08:00:00.000Z")]; - - let sections = build(notes, true, &now_at(&offset), &offset); - - // Chronological grouping would bury an old match in a trailing section. - assert_eq!(keys(§ions), [NoteSectionKey::Results]); - assert_eq!(sections[0].notes.len(), 2); - assert!(!sections[0].show_create_ghost); - } - - #[test] - fn a_section_reports_whether_any_of_its_notes_is_due_soon() { - let offset = utc(); - let mut expiring = note("expiring", "2026-07-25T08:00:00.000Z"); - expiring.lifecycle = NoteLifecycle::Expires { - at: "2026-07-26T00:00:00.000Z".to_string(), - }; - - let sections = build( - vec![expiring, note("plain", "2026-07-25T08:00:00.000Z")], - false, - &now_at(&offset), - &offset, - ); - - let today = sections - .iter() - .find(|s| s.key == NoteSectionKey::Today) - .unwrap(); - assert!(today.has_expiring_notes); - let week = sections - .iter() - .find(|s| s.key == NoteSectionKey::Week) - .unwrap(); - assert!(!week.has_expiring_notes); - } - - #[test] - fn a_distant_deadline_does_not_light_up_the_section_hint() { - let offset = utc(); - let mut expiring = note("expiring", "2026-07-25T08:00:00.000Z"); - expiring.lifecycle = NoteLifecycle::Expires { - at: "2027-01-01T00:00:00.000Z".to_string(), - }; - - let sections = build(vec![expiring], false, &now_at(&offset), &offset); - - // The hint reads "to triage soon"; firing it six months ahead would make - // it permanent background noise. - let today = sections - .iter() - .find(|s| s.key == NoteSectionKey::Today) - .unwrap(); - assert!(!today.has_expiring_notes); - } - - #[test] - fn the_day_boundary_follows_the_local_timezone_not_utc() { - // 23:30 in Paris on 25 July is already 21:30 UTC the same day, but a note - // created at 22:10 UTC is 00:10 local on the 26th — tomorrow, not today. - let paris = offset_from_minutes(-120); - let now = DateTime::parse_from_rfc3339("2026-07-25T21:30:00.000Z") - .unwrap() - .with_timezone(&paris); - - let sections = build( - vec![note("local-today", "2026-07-25T20:00:00.000Z")], - false, - &now, - &paris, - ); - - assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["local-today"]); - } - - #[test] - fn a_note_created_just_after_local_midnight_is_not_yesterday() { - // Same instant read in UTC would fall on the previous day and land in - // "this week" instead of "today". - let paris = offset_from_minutes(-120); - let now = DateTime::parse_from_rfc3339("2026-07-26T08:00:00.000Z") - .unwrap() - .with_timezone(&paris); - - let sections = build( - vec![note("after-midnight", "2026-07-25T22:10:00.000Z")], - false, - &now, - &paris, - ); - - assert_eq!(ids_in(§ions, NoteSectionKey::Today), ["after-midnight"]); - } - - #[test] - fn an_unparsable_creation_date_keeps_the_note_reachable() { - let offset = utc(); - - let sections = build( - vec![note("corrupt", "pas une date")], - false, - &now_at(&offset), - &offset, - ); - - assert_eq!(ids_in(§ions, NoteSectionKey::Older), ["corrupt"]); - } - - #[test] - fn a_note_created_in_the_future_is_not_swallowed() { - let offset = utc(); - - let sections = build( - vec![note("future", "2030-01-01T00:00:00.000Z")], - false, - &now_at(&offset), - &offset, - ); - - // is_within_days rejects negative elapsed time, so it must still surface - // somewhere rather than vanish between the branches. - assert_eq!(ids_in(§ions, NoteSectionKey::Older), ["future"]); - } - - #[test] - fn the_order_received_is_preserved_inside_a_section() { - let offset = utc(); - let notes = vec![ - note("first", "2026-07-25T08:00:00.000Z"), - note("second", "2026-07-25T07:00:00.000Z"), - ]; - - let sections = build(notes, false, &now_at(&offset), &offset); - - // The SQL ORDER BY decides; this module must not re-sort. - assert_eq!( - ids_in(§ions, NoteSectionKey::Today), - ["first", "second"] - ); - } -} diff --git a/src-tauri/src/domain/space.rs b/src-tauri/src/domain/space.rs index 02a69d3..b1a54e1 100644 --- a/src-tauri/src/domain/space.rs +++ b/src-tauri/src/domain/space.rs @@ -1,5 +1,4 @@ -//! L'espace : le classeur dans lequel les notes sont rangées. C'est le -//! `space_id` de la note qui porte la relation. +//! L'espace : le classeur dans lequel les notes sont rangées. //! //! Aucune entrée « Tous les espaces » côté données : c'est un mode d'affichage, //! et en créer un ferait ranger des notes dedans. @@ -7,18 +6,17 @@ use serde::{Deserialize, Serialize}; use specta::Type; -use super::rules::ValidationError; +use super::error::ValidationError; #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct Space { pub id: String, - /// L'unicité, insensible à la casse, est tranchée par la persistance : un - /// doublon ressort en `ErrorCode::DuplicateSpaceName`. + /// Unicité insensible à la casse, tranchée par la persistance. pub name: String, } -/// Pas d'identifiant : il est attribué par la persistance. +/// Pas d'identifiant : la persistance l'attribue. #[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct SpaceDraft { @@ -26,9 +24,8 @@ pub struct SpaceDraft { } impl SpaceDraft { - /// Nom détouré et non vide. Le détourage n'est pas cosmétique : - /// `COLLATE NOCASE` ne replie pas les espaces, donc « Perso » et « Perso » - /// suivi d'une espace cohabiteraient, affichés à l'identique. + /// ⚠️ Le détourage n'est pas cosmétique : `COLLATE NOCASE` ne replie pas les + /// espaces, donc « Perso » et « Perso » cohabiteraient, identiques à l'écran. pub fn validated_name(&self) -> Result { let trimmed = self.name.trim(); if trimmed.is_empty() { @@ -56,32 +53,10 @@ pub fn validate_move_target(id: &str, target_id: &str) -> Result<(), ValidationE Ok(()) } -/// `rename_all` est sans effet tant que les champs tiennent en un mot : ces -/// tests échoueront le jour où un `created_at` s'ajoutera sans l'attribut, au -/// lieu de laisser le front lire `undefined`. #[cfg(test)] mod tests { use super::*; - #[test] - fn a_space_serialises_with_the_keys_the_front_reads() { - let json = serde_json::to_value(Space { - id: "s-1".to_string(), - name: "Perso".to_string(), - }) - .unwrap(); - - assert_eq!(json, serde_json::json!({ "id": "s-1", "name": "Perso" })); - } - - #[test] - fn a_draft_is_read_from_the_payload_the_front_sends() { - let draft: SpaceDraft = - serde_json::from_value(serde_json::json!({ "name": "Boulot" })).unwrap(); - - assert_eq!(draft.name, "Boulot"); - } - fn draft(name: &str) -> SpaceDraft { SpaceDraft { name: name.to_string(), diff --git a/src-tauri/src/domain/tag.rs b/src-tauri/src/domain/tag.rs new file mode 100644 index 0000000..8676d7e --- /dev/null +++ b/src-tauri/src/domain/tag.rs @@ -0,0 +1,69 @@ +//! Normalisation des tags. + +/// Trim, `#` de tête, vides et doublons. +/// +/// Règle unique : le front envoie ce que l'utilisateur a tapé, l'écriture comme +/// la requête passent par ici — sinon un `#urgent` saisi ne retrouverait pas le +/// `urgent` stocké. La déduplication est insensible à la casse et garde la +/// première graphie ; `COLLATE NOCASE` (migration 2) prolonge la règle au corpus. +pub fn normalize(tags: &[String]) -> Vec { + let mut seen: Vec = Vec::new(); + let mut normalized: Vec = Vec::new(); + + for tag in tags { + let cleaned = tag.trim().trim_start_matches('#').trim(); + if cleaned.is_empty() { + continue; + } + + let folded = cleaned.to_lowercase(); + if seen.contains(&folded) { + continue; + } + + seen.push(folded); + normalized.push(cleaned.to_string()); + } + + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalized(tags: &[&str]) -> Vec { + normalize(&tags.iter().copied().map(String::from).collect::>()) + } + + #[test] + fn padding_and_blanks_are_dropped() { + assert_eq!( + normalized(&[" urgent ", "", " ", "later"]), + ["urgent", "later"] + ); + } + + #[test] + fn a_duplicate_keeps_its_first_spelling() { + assert_eq!(normalized(&["Urgent", "urgent", "URGENT"]), ["Urgent"]); + } + + #[test] + fn only_leading_hashes_are_stripped() { + assert_eq!(normalized(&["##c++", "a#b"]), ["c++", "a#b"]); + } + + #[test] + fn a_tag_reduced_to_nothing_is_dropped_rather_than_stored_empty() { + // " # " trims to "#", then to "" — storing that would put a blank facet + // in the rail that selects every note carrying it. + assert!(normalized(&[" # ", "#"]).is_empty()); + } + + #[test] + fn tag_case_folding_reaches_beyond_ascii() { + // SQLite's NOCASE would not collapse these; `to_lowercase` is Unicode. + assert_eq!(normalized(&["Étape", "étape"]), ["Étape"]); + } +} diff --git a/src-tauri/src/domain/view.rs b/src-tauri/src/domain/view.rs index a197d40..b1e516c 100644 --- a/src-tauri/src/domain/view.rs +++ b/src-tauri/src/domain/view.rs @@ -1,48 +1,39 @@ -//! Ce que l'utilisateur demande à voir ([`NotesQuery`]), ce que le canevas -//! affiche en retour ([`NotesView`]), et l'assemblage de l'un vers l'autre. +//! Ce que l'utilisateur demande à voir ([`NotesQuery`]) et ce que le canevas +//! affiche en retour ([`NotesView`]). //! //! Aucun type intermédiaire « liste de notes » n'est exposé au front : il //! inviterait à refiltrer côté interface. -//! -//! [`build`] est pure — elle reçoit les notes que le SQL a dégrossies et n'ouvre -//! aucune connexion. Le partage : **SQL** pour ce qu'il indexe (espace, -//! épinglage, cycle de vie, langage, tag), **ici** pour la recherche texte qui -//! demande un repliage Unicode, [`super::sections`] pour le regroupement. - -use chrono::DateTime; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; +use super::language::Language; use super::note::{DisplayNote, Note}; -use super::rules::{self, ValidationError}; -use super::sections; +use super::{search, section, tag}; -/// Tout y est explicite : la requête ne lit ni horloge ni fuseau, ce qui la -/// rend reproductible en test. +/// Ni horloge ni fuseau lus ici : tout est explicite, donc reproductible en test. #[derive(Debug, Clone, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotesQuery { /// `None` = « tous les espaces » — un choix, pas une absence de choix : il /// n'existe aucun espace « Tous » côté données. pub space_id: Option, - /// Cherché dans le titre, les tags et le contenu. Vide = pas de recherche. + /// Vide = pas de recherche. pub search: String, pub filter: NoteFilter, - /// Tags du rail. Une note passe si elle en porte **au moins un**. + /// Une note passe si elle porte **au moins un** de ces tags. pub tags: Vec, - /// Langages du rail, même sémantique d'union. Vide = tous. - pub languages: Vec, - /// Instant de référence ISO 8601 UTC, fourni par `ClockService`. - pub now: String, + /// Même sémantique d'union. Vide = tous. + pub languages: Vec, + pub now: DateTime, /// ⚠️ `Date#getTimezoneOffset()`, dont la valeur est l'**opposé** du décalage - /// (UTC+2 donne −120). Nécessaire parce que les sections raisonnent en jours - /// locaux : à 23 h à Paris, `now` en UTC est déjà demain. + /// (UTC+2 donne −120). Les sections raisonnent en jours locaux : à 23 h à + /// Paris, `now` en UTC est déjà demain. pub tz_offset_minutes: i32, } -/// Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date -/// d'expiration, c'est-à-dire celles dont on n'a pas encore décidé du sort. +/// `Untriaged` = notes portant une échéance, celles dont le sort n'est pas décidé. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum NoteFilter { @@ -51,30 +42,25 @@ pub enum NoteFilter { Untriaged, } -/// Ce que les rails ont à proposer. Regroupées parce que la persistance les -/// calcule ensemble ; ne traverse pas le pont. +/// Ce que les rails ont à proposer. Ne traverse pas le pont. #[derive(Debug, Clone, Default)] pub struct Facets { pub tags: Vec, - pub languages: Vec, + pub languages: Vec, } -/// Ce que le canevas affiche, tel quel. #[derive(Debug, Clone, Serialize, Type)] #[serde(rename_all = "camelCase")] pub struct NotesView { pub sections: Vec, - /// Portés à l'**espace**, pas au filtre courant : n'afficher que les tags des - /// notes déjà filtrées rendrait le rail inutilisable dès la 1re sélection. + /// Portées à l'**espace**, pas au filtre courant : n'offrir que les facettes + /// des notes déjà filtrées viderait le rail dès la 1re sélection. pub available_tags: Vec, - /// Portés à l'espace, même raison. - pub available_languages: Vec, - /// Une recherche ou une facette est active. Le front distingue ainsi - /// « aucun résultat » d'« espace vide ». + pub available_languages: Vec, + /// Distingue « aucun résultat » d'« espace vide ». pub is_filtering: bool, - /// Notes retenues, toutes sections confondues. `u32` et non `usize` : Specta - /// refuse d'exporter les types de la taille d'un `BigInt`, que JSON ne sait - /// pas rendre sans perte de précision. + /// `u32` et non `usize` : Specta refuse d'exporter un type de la taille d'un + /// `BigInt`, que JSON ne rend pas sans perte de précision. pub matched: u32, } @@ -82,15 +68,13 @@ pub struct NotesView { #[serde(rename_all = "camelCase")] pub struct NoteSection { pub key: NoteSectionKey, - /// Au moins une note arrive à échéance, au sens du seuil unique de `note`. pub has_expiring_notes: bool, pub notes: Vec, - /// Affiche la carte fantôme « coller ou créer » en fin de section. pub show_create_ghost: bool, } -/// Sert de **clé de traduction** côté front (`sections.`) : aucun libellé -/// lisible ne traverse le pont. +/// **Clé de traduction** côté front (`sections.`) : aucun libellé lisible +/// ne traverse le pont. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Type)] #[serde(rename_all = "camelCase")] pub enum NoteSectionKey { @@ -101,308 +85,34 @@ pub enum NoteSectionKey { Results, } -/// Vue complète. Une vue vide est une réponse valide (premier lancement, ou -/// recherche infructueuse — `is_filtering` distingue les deux). -/// -/// Un `now` illisible est **refusé**, jamais remplacé par l'horloge du serveur : -/// un repli muet ferait basculer tout le découpage sur un autre instant. -pub fn build( - notes: Vec, - facets: Facets, - request: &NotesQuery, -) -> Result { +/// Une vue vide est une réponse valide : premier lancement, ou recherche +/// infructueuse — `is_filtering` distingue les deux. +pub fn build(notes: Vec, facets: Facets, request: &NotesQuery) -> NotesView { let mut notes = notes; - // La recherche porte aussi sur les tags, déjà rattachés par la persistance. let needle = request.search.trim().to_lowercase(); if !needle.is_empty() { - notes.retain(|note| rules::matches(note, &needle)); + notes.retain(|note| search::matches(note, &needle)); } - // Le filtre rapide (épinglées / à trier) ne bascule pas en mode résultats : - // il restreint une vue qui reste chronologique. Une recherche ou une - // facette — tag ou langage —, si. + // Un filtre rapide restreint une vue qui reste chronologique ; une recherche + // ou une facette, elle, bascule en liste plate. let is_filtering = !needle.is_empty() - || !rules::normalize_tags(&request.tags).is_empty() + || !tag::normalize(&request.tags).is_empty() || !request.languages.is_empty(); - // Le nombre de notes d'un espace ne déborde pas d'un `u32`, et une saturation - // reste préférable à une panne : ce compteur ne sert qu'à un libellé. + // Saturer vaut mieux que paniquer : ce compteur ne sert qu'à un libellé. let matched = u32::try_from(notes.len()).unwrap_or(u32::MAX); - let offset = sections::offset_from_minutes(request.tz_offset_minutes); - let now = DateTime::parse_from_rfc3339(&request.now) - .map_err(|_| { - ValidationError::new( - "now", - format!("« {} » n'est pas un instant ISO 8601", request.now), - ) - })? - .with_timezone(&offset); + let offset = section::offset_from_minutes(request.tz_offset_minutes); - Ok(NotesView { - sections: sections::build(notes, is_filtering, &now, &offset), + NotesView { + sections: section::build(notes, is_filtering, request.now, offset), available_tags: facets.tags, available_languages: facets.languages, is_filtering, matched, - }) + } } #[cfg(test)] -mod tests { - use super::build as try_build; - use super::*; - use crate::domain::fixtures::note as sample; - use crate::domain::note::decorate; - - const NOW: &str = "2026-07-25T09:00:00.000Z"; - - fn displayed() -> DisplayNote { - let now = DateTime::parse_from_rfc3339(NOW).unwrap(); - decorate(sample(), &now) - } - - #[test] - fn a_view_serialises_with_camel_case_keys() { - let view = NotesView { - sections: vec![NoteSection { - key: NoteSectionKey::Week, - notes: vec![displayed()], - has_expiring_notes: false, - show_create_ghost: true, - }], - available_tags: vec!["auth".to_string()], - available_languages: vec!["json".to_string()], - is_filtering: false, - matched: 1, - }; - - let json = serde_json::to_value(view).unwrap(); - - assert!(json.get("availableTags").is_some()); - assert!(json.get("availableLanguages").is_some()); - assert!(json.get("isFiltering").is_some()); - assert!(json.get("available_tags").is_none()); - assert!(json.get("available_languages").is_none()); - assert!(json["sections"][0].get("hasExpiringNotes").is_some()); - assert!(json["sections"][0].get("showCreateGhost").is_some()); - } - - #[test] - fn a_section_key_serialises_as_the_translation_key_the_front_expects() { - let section = NoteSection { - key: NoteSectionKey::Older, - notes: Vec::new(), - has_expiring_notes: false, - show_create_ghost: false, - }; - - let json = serde_json::to_value(section).unwrap(); - - // The front builds `sections.older` from this; serde's default would - // emit "Older" and the lookup would miss. - assert_eq!(json["key"], "older"); - } - - #[test] - fn a_query_is_read_from_the_camel_case_payload_the_front_sends() { - let query: NotesQuery = serde_json::from_value(serde_json::json!({ - "spaceId": "s-1", - "search": "deploy", - "filter": "untriaged", - "tags": ["urgent"], - "languages": ["json", "yml"], - "now": "2026-07-25T09:00:00.000Z", - "tzOffsetMinutes": -120 - })) - .unwrap(); - - assert_eq!(query.space_id.as_deref(), Some("s-1")); - assert_eq!(query.filter, NoteFilter::Untriaged); - assert_eq!(query.languages, ["json", "yml"]); - assert_eq!(query.tz_offset_minutes, -120); - } - - #[test] - fn a_null_space_is_read_as_every_space() { - // The front sends null, not an omitted key, when the user picks - // "all spaces" — that is a choice, not a missing value. - let query: NotesQuery = serde_json::from_value(serde_json::json!({ - "spaceId": null, - "search": "", - "filter": "all", - "tags": [], - "languages": [], - "now": "2026-07-25T09:00:00.000Z", - "tzOffsetMinutes": 0 - })) - .unwrap(); - - assert!(query.space_id.is_none()); - } - - /// Les cas nominaux fournissent tous un instant valide ; seul le test dédié - /// s'intéresse au refus. - fn build(notes: Vec, facets: Facets, request: &NotesQuery) -> NotesView { - try_build(notes, facets, request).unwrap() - } - - fn request() -> NotesQuery { - NotesQuery { - space_id: None, - search: String::new(), - filter: NoteFilter::All, - tags: Vec::new(), - languages: Vec::new(), - now: NOW.to_string(), - tz_offset_minutes: 0, - } - } - - fn note(id: &str, title: &str) -> Note { - Note { - id: id.to_string(), - title: title.to_string(), - created_at: "2026-07-25T08:00:00.000Z".to_string(), - ..sample() - } - } - - fn keys(view: &NotesView) -> Vec { - view.sections.iter().map(|section| section.key).collect() - } - - #[test] - fn an_empty_search_keeps_every_note_and_reports_no_filtering() { - let view = build( - vec![note("a", "Un"), note("b", "Deux")], - Facets::default(), - &request(), - ); - - assert_eq!(view.matched, 2); - assert!(!view.is_filtering); - assert_eq!(keys(&view), [NoteSectionKey::Today, NoteSectionKey::Week]); - } - - #[test] - fn a_search_narrows_the_notes_and_collapses_the_sections() { - let notes = vec![note("a", "Déploiement"), note("b", "Autre chose")]; - - let view = build( - notes, - Facets::default(), - &NotesQuery { - search: " DÉPLOI ".to_string(), - ..request() - }, - ); - - // Trimmed and case-folded before matching, then flattened: a search - // result reads as a list, not as date buckets. - assert_eq!(view.matched, 1); - assert!(view.is_filtering); - assert_eq!(keys(&view), [NoteSectionKey::Results]); - } - - #[test] - fn a_selected_tag_counts_as_filtering_even_with_no_search() { - let view = build( - vec![note("a", "Un")], - Facets::default(), - &NotesQuery { - tags: vec!["urgent".to_string()], - ..request() - }, - ); - - assert!(view.is_filtering); - assert_eq!(keys(&view), [NoteSectionKey::Results]); - } - - #[test] - fn a_tag_that_normalises_to_nothing_does_not_count_as_filtering() { - // " # " is not a selection; treating it as one would flatten the canvas - // and tell the user a search is running when none is. - let view = build( - vec![note("a", "Un")], - Facets::default(), - &NotesQuery { - tags: vec![" # ".to_string()], - ..request() - }, - ); - - assert!(!view.is_filtering); - } - - #[test] - fn a_fruitless_search_reports_filtering_with_zero_matches() { - // The front tells "no result" from "empty space" on exactly this pair. - let view = build( - vec![note("a", "Un")], - Facets::default(), - &NotesQuery { - search: "introuvable".to_string(), - ..request() - }, - ); - - assert_eq!(view.matched, 0); - assert!(view.is_filtering); - } - - #[test] - fn the_rail_facets_are_passed_through_untouched() { - // They are scoped to the space by the query, not to the current search: - // narrowing them would empty the rails on the first selection. - let view = build( - vec![note("a", "Un")], - Facets { - tags: vec!["api".to_string(), "auth".to_string()], - languages: vec!["json".to_string(), "txt".to_string()], - }, - &NotesQuery { - search: "introuvable".to_string(), - ..request() - }, - ); - - assert_eq!(view.available_tags, ["api", "auth"]); - assert_eq!(view.available_languages, ["json", "txt"]); - } - - #[test] - fn a_selected_language_counts_as_filtering_like_a_selected_tag() { - // Both rails are facet rails: selecting in either one turns the canvas - // into a flat result list. Only the quick filters keep the date buckets. - let view = build( - vec![note("a", "Un")], - Facets::default(), - &NotesQuery { - languages: vec!["json".to_string()], - ..request() - }, - ); - - assert!(view.is_filtering); - assert_eq!(keys(&view), [NoteSectionKey::Results]); - } - - #[test] - fn an_unreadable_reference_instant_is_refused_rather_than_replaced() { - // Falling back to the server clock would silently re-cut every section - // on another instant: notes would change day with nothing to show for it. - let error = try_build( - vec![note("a", "Un")], - Facets::default(), - &NotesQuery { - now: "hier".to_string(), - ..request() - }, - ) - .unwrap_err(); - - assert_eq!(error.field, "now"); - } -} +mod tests; diff --git a/src-tauri/src/domain/view/tests.rs b/src-tauri/src/domain/view/tests.rs new file mode 100644 index 0000000..ad1fe35 --- /dev/null +++ b/src-tauri/src/domain/view/tests.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::domain::fixtures::{NOW, at, note as sample}; + +fn request() -> NotesQuery { + NotesQuery { + space_id: None, + search: String::new(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: at(NOW), + tz_offset_minutes: 0, + } +} + +fn note(id: &str, title: &str) -> Note { + Note { + id: id.to_string(), + title: title.to_string(), + created_at: at("2026-07-25T08:00:00.000Z"), + ..sample() + } +} + +fn keys(view: &NotesView) -> Vec { + view.sections.iter().map(|section| section.key).collect() +} + +#[test] +fn an_empty_search_keeps_every_note_and_reports_no_filtering() { + let view = build( + vec![note("a", "Un"), note("b", "Deux")], + Facets::default(), + &request(), + ); + + assert_eq!(view.matched, 2); + assert!(!view.is_filtering); + assert_eq!(keys(&view), [NoteSectionKey::Today, NoteSectionKey::Week]); +} + +#[test] +fn a_search_narrows_the_notes_and_collapses_the_sections() { + let notes = vec![note("a", "Déploiement"), note("b", "Autre chose")]; + + let view = build( + notes, + Facets::default(), + &NotesQuery { + search: " DÉPLOI ".to_string(), + ..request() + }, + ); + + // Trimmed and case-folded before matching, then flattened: a search + // result reads as a list, not as date buckets. + assert_eq!(view.matched, 1); + assert!(view.is_filtering); + assert_eq!(keys(&view), [NoteSectionKey::Results]); +} + +#[test] +fn a_selected_tag_counts_as_filtering_even_with_no_search() { + let view = build( + vec![note("a", "Un")], + Facets::default(), + &NotesQuery { + tags: vec!["urgent".to_string()], + ..request() + }, + ); + + assert!(view.is_filtering); + assert_eq!(keys(&view), [NoteSectionKey::Results]); +} + +#[test] +fn a_tag_that_normalises_to_nothing_does_not_count_as_filtering() { + // " # " is not a selection; treating it as one would flatten the canvas + // and tell the user a search is running when none is. + let view = build( + vec![note("a", "Un")], + Facets::default(), + &NotesQuery { + tags: vec![" # ".to_string()], + ..request() + }, + ); + + assert!(!view.is_filtering); +} + +#[test] +fn a_fruitless_search_reports_filtering_with_zero_matches() { + // The front tells "no result" from "empty space" on exactly this pair. + let view = build( + vec![note("a", "Un")], + Facets::default(), + &NotesQuery { + search: "introuvable".to_string(), + ..request() + }, + ); + + assert_eq!(view.matched, 0); + assert!(view.is_filtering); +} + +#[test] +fn the_rail_facets_are_passed_through_untouched() { + // They are scoped to the space by the query, not to the current search: + // narrowing them would empty the rails on the first selection. + let view = build( + vec![note("a", "Un")], + Facets { + tags: vec!["api".to_string(), "auth".to_string()], + languages: vec![Language::Json, Language::Txt], + }, + &NotesQuery { + search: "introuvable".to_string(), + ..request() + }, + ); + + assert_eq!(view.available_tags, ["api", "auth"]); + assert_eq!(view.available_languages, [Language::Json, Language::Txt]); +} + +#[test] +fn a_selected_language_counts_as_filtering_like_a_selected_tag() { + // Both rails are facet rails: selecting in either one turns the canvas + // into a flat result list. Only the quick filters keep the date buckets. + let view = build( + vec![note("a", "Un")], + Facets::default(), + &NotesQuery { + languages: vec![Language::Json], + ..request() + }, + ); + + assert!(view.is_filtering); + assert_eq!(keys(&view), [NoteSectionKey::Results]); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aeb5d21..39cfe51 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ -mod commands; +// Publics : `tests/` est un crate à part, qui ne voit du binaire que son API. +pub mod commands; +pub mod domain; +pub mod storage; + #[cfg(desktop)] mod desktop; -mod domain; -mod storage; - -use std::sync::Mutex; use tauri::Manager; use tauri_specta::{Builder, collect_commands}; @@ -13,31 +13,25 @@ use commands::notes::{create_note, delete_note, query_notes, update_note}; use commands::spaces::{create_space, delete_space, list_spaces, rename_space}; use commands::tray::sync_tray; -/// Destination du `bindings.ts` généré. Il est versionné : le front ne compile -/// pas sans lui. -/// -/// Résolu depuis le manifeste et non depuis le répertoire courant : ni `tauri dev` -/// ni `cargo run --manifest-path` ne garantissent lequel c'est, et un chemin -/// relatif écrivait le fichier à côté du dépôt sans rien signaler. +/// Résolu depuis le manifeste et non du répertoire courant : ni `tauri dev` ni +/// `cargo run --manifest-path` ne garantissent lequel c'est, et un chemin relatif +/// écrivait le fichier à côté du dépôt sans rien signaler. const BINDINGS_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../src/app/core/ipc/bindings.ts" ); -/// Réécrit `bindings.ts` sans lancer l'application — c'est ce qu'appelle le -/// binaire `export-bindings`, et donc `npm run bindings`. +/// Réécrit `bindings.ts` sans lancer l'application. /// -/// Volontairement pas un `#[cfg(test)]` : sous Windows l'exécutable de test vit -/// dans `target/debug/deps/`, où le `WebView2Loader.dll` posé par `tauri-build` -/// est absent, et le seul fait de lier `export` y empêche le binaire de démarrer. +/// ⚠️ Pas un `#[cfg(test)]` : sous Windows l'exécutable de test vit dans +/// `target/debug/deps/`, sans le `WebView2Loader.dll` que lier `export` exige — +/// le binaire de test n'y démarre plus du tout. pub fn export_bindings() -> Result<(), specta_typescript::Error> { ipc_builder().export(specta_typescript::Typescript::default(), BINDINGS_PATH) } -/// Source **unique** des signatures : ce qui est collecté ici est à la fois -/// enregistré auprès de Tauri et écrit dans le `bindings.ts` du front. Une -/// commande absente de cette liste n'existe donc plus côté TypeScript non plus, -/// là où l'ancien `generate_handler!` laissait les deux dériver l'un de l'autre. +/// Source **unique** des signatures : cette liste enregistre auprès de Tauri +/// *et* écrit `bindings.ts`. Une commande qui n'y est pas n'existe nulle part. fn ipc_builder() -> Builder { Builder::::new().commands(collect_commands![ query_notes, @@ -52,61 +46,69 @@ fn ipc_builder() -> Builder { ]) } -/// Point d'entrée de l'application, natif sur mobile via `mobile_entry_point`. #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = ipc_builder(); - // Régénéré à chaque lancement de `npm run tauri dev`, pour qu'une signature - // Rust modifiée casse le front tout de suite. Pas en release : le `src/` du - // front n'existe pas à côté d'un binaire installé. + // Pas en release : le `src/` du front n'existe pas à côté d'un binaire installé. #[cfg(debug_assertions)] export_bindings().expect("échec de la génération des bindings TypeScript"); tauri::Builder::default() + // En premier : les plugins suivants journalisent déjà. + .plugin( + tauri_plugin_log::Builder::new() + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::LogDir { file_name: None }, + )) + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )) + .level(log::LevelFilter::Info) + .build(), + ) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_clipboard_manager::init()) .setup(|app| { - // L'updater est absent des cibles mobiles (voir Cargo.toml), sinon - // la compilation Android/iOS bute sur un crate inconnu. + // Absent des cibles mobiles (voir Cargo.toml). #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; - // Idem : un système mobile ne laisse pas une application écouter le - // clavier hors de sa fenêtre. La barre système, elle, n'est pas - // créée ici — elle attend du front ses libellés traduits. + // Idem. La barre système, elle, n'est pas créée ici : elle attend + // du front ses libellés traduits. #[cfg(desktop)] - desktop::register_shortcuts(app.handle())?; + desktop::shortcut::register(app.handle())?; // Seul emplacement inscriptible garanti une fois l'app installée. let directory = app.path().app_data_dir()?; std::fs::create_dir_all(&directory)?; - // Connexion unique derrière un mutex : `Connection` n'est pas - // `Sync`, et deux commandes peuvent se chevaucher. let connection = storage::open(&directory.join(storage::DB_FILE_NAME))?; - app.manage(Mutex::new(connection)); + app.manage(commands::Db::new(connection)); Ok(()) }) - // Fermer range dans la barre système au lieu de quitter : l'application - // est faite pour rester à portée de raccourci, et la quitter à chaque - // fois rendrait `Ctrl+Alt+V` inutile. + // Fermer range dans la barre système au lieu de quitter — l'application + // est faite pour rester à portée de raccourci. // - // ⚠️ Uniquement s'il y a une barre système où la retrouver. Sans elle, + // ⚠️ Uniquement s'il y a une barre système où la retrouver : sans elle, // cacher la fenêtre laisserait un processus que plus rien ne rappelle. - .on_window_event(|_window, _event| { - #[cfg(desktop)] - if let tauri::WindowEvent::CloseRequested { api, .. } = _event - && desktop::has_tray(_window.app_handle()) - { - api.prevent_close(); - let _ = _window.hide(); - } - }) + .on_window_event( + // Le préfixe `_` garde la compilation mobile silencieuse. + #[allow(clippy::used_underscore_binding)] + |_window, _event| { + #[cfg(desktop)] + if let tauri::WindowEvent::CloseRequested { api, .. } = _event + && desktop::tray::exists(_window.app_handle()) + { + api.prevent_close(); + let _ = _window.hide(); + } + }, + ) .invoke_handler(builder.invoke_handler()) .run(tauri::generate_context!()) .expect("erreur au lancement de l'application Tauri"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3fc334f..51b599a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - devbox_lib::run() + devbox_lib::run(); } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..cecd34b --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,75 @@ +//! SQLite embarqué, base dans `app_data_dir()`. **Aucune règle métier** : elles +//! sont dans `crate::domain`. + +pub mod error; +pub mod migration; +pub mod notes; +pub mod schema; +pub mod spaces; + +use std::path::Path; + +use diesel::connection::SimpleConnection; +use diesel::prelude::*; + +pub use error::StorageError; + +pub const DB_FILE_NAME: &str = "devbox.sqlite3"; + +/// Ouvre la base (en la créant au besoin), la configure, migre. +pub fn open(path: &Path) -> Result { + let mut connection = SqliteConnection::establish(&path.to_string_lossy()) + .map_err(|error| StorageError::Migration(error.to_string()))?; + configure(&mut connection)?; + migration::run(&mut connection)?; + + Ok(connection) +} + +/// Base éphémère. Publique pour les tests d'intégration, qui ne voient du crate +/// que son API. +pub fn open_in_memory() -> Result { + let mut connection = SqliteConnection::establish(":memory:") + .map_err(|error| StorageError::Migration(error.to_string()))?; + configure(&mut connection)?; + migration::run(&mut connection)?; + + Ok(connection) +} + +fn configure(connection: &mut SqliteConnection) -> Result<(), StorageError> { + // ⚠️ `foreign_keys` se règle **par connexion** et est désactivé par défaut : + // sans lui les `ON DELETE CASCADE` sont inertes et les tags d'une note + // supprimée resteraient orphelins. WAL : un lecteur ne bloque plus un écrivain. + connection.batch_execute( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL;", + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use diesel::sql_types::Integer; + + /// Reste ici plutôt que dans `tests/` : `configure` est privée. + #[test] + fn foreign_keys_are_enforced() { + #[derive(QueryableByName)] + struct ForeignKeys { + #[diesel(sql_type = Integer)] + foreign_keys: i32, + } + + let mut connection = open_in_memory().unwrap(); + + let enabled = diesel::sql_query("PRAGMA foreign_keys") + .get_result::(&mut connection) + .unwrap() + .foreign_keys; + + assert_eq!(enabled, 1); + } +} diff --git a/src-tauri/src/storage/error.rs b/src-tauri/src/storage/error.rs new file mode 100644 index 0000000..135738d --- /dev/null +++ b/src-tauri/src/storage/error.rs @@ -0,0 +1,34 @@ +//! Panne de persistance. + +use thiserror::Error; + +/// Les commandes convertissent ces variantes en `AppError` : la variante devient +/// un **code** que le front traduit, et le `Display` n'est plus que le détail +/// technique — c'est pourquoi il peut rester en français. +#[derive(Debug, Error)] +pub enum StorageError { + /// Jamais un `Ok` silencieux : le front croirait avoir enregistré. + #[error("Note introuvable : {0}")] + NoteNotFound(String), + /// Espace visé inexistant : la note n'aurait nulle part où être rangée. + #[error("Espace introuvable : {0}")] + SpaceNotFound(String), + /// Nom déjà pris (comparaison insensible à la casse). + #[error("Un espace nommé « {0} » existe déjà")] + DuplicateSpaceName(String), + /// Colonne qu'aucune écriture de ce code n'aurait pu produire. + #[error("Note « {id} » illisible : le champ « {field} » est hors format")] + CorruptRow { id: String, field: &'static str }, + /// Base portant une migration que ce binaire ne connaît pas : elle a été + /// écrite par une version plus récente de l'application. + #[error("Base de données portant la migration « {0} », inconnue de cette version de DevBox")] + SchemaTooRecent(String), + /// Ouverture ou migration impossible — panne d'avant le premier `SELECT`. + #[error("Migration impossible : {0}")] + Migration(String), + /// `#[from]` : requis par `Connection::transaction`, qui exige de savoir + /// absorber l'erreur de Diesel dans celle de l'appelant. `#[source]` en + /// prime, là où l'ancien `impl Display` écrasé perdait la chaîne de causes. + #[error("Erreur de stockage : {0}")] + Sqlite(#[from] diesel::result::Error), +} diff --git a/src-tauri/src/storage/migration.rs b/src-tauri/src/storage/migration.rs new file mode 100644 index 0000000..eef3720 --- /dev/null +++ b/src-tauri/src/storage/migration.rs @@ -0,0 +1,106 @@ +//! Application des migrations embarquées, et adoption des bases héritées. +//! +//! ⚠️ **Append-only.** Faire évoluer le modèle = ajouter un dossier +//! `migrations/AAAA-MM-JJ-HHMMSS_nom/`, jamais modifier une migration livrée. + +#[cfg(test)] +mod tests; + +use diesel::migration::MigrationSource; +use diesel::prelude::*; +use diesel::sql_types::{Integer, Text}; +use diesel::sqlite::Sqlite; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; + +use super::error::StorageError; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); + +/// Valeur maximale qu'a livrée l'ancien `PRAGMA user_version`. Ne bouge plus : +/// une migration ajoutée aujourd'hui n'a jamais existé sous l'ancien schéma. +const LEGACY_MIGRATION_COUNT: usize = 3; + +#[derive(QueryableByName)] +struct UserVersion { + #[diesel(sql_type = Integer)] + user_version: i32, +} + +fn embedded_versions() -> Result, StorageError> { + let mut versions = MigrationSource::::migrations(&MIGRATIONS) + .map_err(|error| StorageError::Migration(error.to_string()))? + .iter() + .map(|migration| migration.name().version().to_string()) + .collect::>(); + versions.sort(); + + Ok(versions) +} + +/// Fait adopter par Diesel l'historique qu'écrivait l'ancien `PRAGMA user_version`. +/// +/// Sans elle, une base déjà installée rejouerait la migration initiale sur des +/// tables existantes. Les `n` premières sont donc marquées appliquées sans être +/// exécutées, et le pragma remis à zéro — deux sources de vérité sur l'état du +/// schéma finiraient par diverger. +fn adopt_legacy_history( + connection: &mut SqliteConnection, + embedded: &[String], +) -> Result<(), StorageError> { + let legacy: i32 = diesel::sql_query("PRAGMA user_version") + .get_result::(connection)? + .user_version; + + // Zéro : base neuve, ou passée par ici lors d'une ouverture précédente. + if legacy <= 0 { + return Ok(()); + } + + // Crée `__diesel_schema_migrations` si elle manque — l'insertion suit. + connection + .applied_migrations() + .map_err(|error| StorageError::Migration(error.to_string()))?; + + let adopted = usize::try_from(legacy) + .unwrap_or(0) + .min(LEGACY_MIGRATION_COUNT) + .min(embedded.len()); + + connection.transaction(|connection| { + for version in &embedded[..adopted] { + diesel::sql_query( + "INSERT OR IGNORE INTO __diesel_schema_migrations (version) VALUES (?)", + ) + .bind::(version) + .execute(connection)?; + } + diesel::sql_query("PRAGMA user_version = 0").execute(connection)?; + + Ok::<_, StorageError>(()) + }) +} + +pub fn run(connection: &mut SqliteConnection) -> Result<(), StorageError> { + let embedded = embedded_versions()?; + + adopt_legacy_history(connection, &embedded)?; + + // Une migration appliquée qu'on ne connaît pas signale une base écrite par + // une version plus récente : refuser vaut mieux qu'écraser ses données. + let applied = connection + .applied_migrations() + .map_err(|error| StorageError::Migration(error.to_string()))?; + if let Some(unknown) = applied + .iter() + .map(ToString::to_string) + .find(|version| !embedded.contains(version)) + { + return Err(StorageError::SchemaTooRecent(unknown)); + } + + connection + .run_pending_migrations(MIGRATIONS) + .map_err(|error| StorageError::Migration(error.to_string()))?; + + Ok(()) +} diff --git a/src-tauri/src/storage/migration/tests.rs b/src-tauri/src/storage/migration/tests.rs new file mode 100644 index 0000000..e061e15 --- /dev/null +++ b/src-tauri/src/storage/migration/tests.rs @@ -0,0 +1,171 @@ +use diesel::connection::SimpleConnection; +use diesel::sql_types::BigInt; + +use super::*; +use crate::storage::{self, DB_FILE_NAME, configure, open, open_in_memory, schema}; + +/// Le SQL de la migration initiale tel qu'il a été livré. Rejoué à la main, +/// il fabrique une base « héritée » : schéma en place, `user_version` posé, +/// aucune trace côté Diesel. +const LEGACY_SCHEMA: &str = include_str!("../../../migrations/2026-07-25-000001_initial/up.sql"); +const LEGACY_FOLD_TAG_CASE: &str = + include_str!("../../../migrations/2026-07-25-000002_fold_tag_case/up.sql"); + +#[derive(QueryableByName)] +struct Count { + #[diesel(sql_type = BigInt)] + count: i64, +} + +fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + diesel::sql_query(query) + .get_result::(connection) + .unwrap() + .count +} + +fn user_version(connection: &mut SqliteConnection) -> i32 { + diesel::sql_query("PRAGMA user_version") + .get_result::(connection) + .unwrap() + .user_version +} + +/// Base au schéma d'origine, versionnée comme l'ancien code le faisait. +fn legacy_database(sql: &[&str], version: i32) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + configure(&mut connection).unwrap(); + for statements in sql { + connection.batch_execute(statements).unwrap(); + } + connection + .batch_execute(&format!("PRAGMA user_version = {version}")) + .unwrap(); + + connection +} + +#[test] +fn opening_twice_is_idempotent() { + let directory = std::env::temp_dir().join(format!("devbox-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join(DB_FILE_NAME); + + open(&path).unwrap(); + // A second open must find every migration already applied and not + // attempt to re-create the tables. + let mut connection = open(&path).unwrap(); + + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); + + std::fs::remove_dir_all(&directory).ok(); +} + +#[test] +fn a_fresh_database_applies_every_embedded_migration() { + let mut connection = open_in_memory().unwrap(); + + let applied = connection.applied_migrations().unwrap(); + assert_eq!(applied.len(), embedded_versions().unwrap().len()); +} + +#[test] +fn a_v1_database_upgrades_and_folds_tag_case() { + let mut connection = legacy_database( + &[ + LEGACY_SCHEMA, + "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); + INSERT INTO notes VALUES + ('n-1', 's-1', 'A', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', + '2026-07-25T09:00:00.000Z', 'permanent', NULL), + ('n-2', 's-1', 'B', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', + '2026-07-25T09:00:00.000Z', 'permanent', NULL); + INSERT INTO note_tags VALUES ('n-1', 'Urgent'), ('n-2', 'urgent');", + ], + 1, + ); + + // Passing at all is half the assertion: replaying the initial migration + // on these tables would fail on `CREATE TABLE spaces`. + run(&mut connection).unwrap(); + + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); + + // Both rows survive: the collation folds the facet, it does not drop data. + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM note_tags"), + 2 + ); + + // But the rail now sees one tag where it used to see two. + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM (SELECT DISTINCT tag FROM note_tags)" + ), + 1 + ); +} + +#[test] +fn a_v2_database_gains_the_language_index_without_touching_its_notes() { + let mut connection = legacy_database( + &[ + LEGACY_SCHEMA, + LEGACY_FOLD_TAG_CASE, + "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); + INSERT INTO notes VALUES + ('n-1', 's-1', 'A', 'json', '', '', 0, '2026-07-25T09:00:00.000Z', + '2026-07-25T09:00:00.000Z', 'permanent', NULL);", + ], + 2, + ); + + run(&mut connection).unwrap(); + + assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM sqlite_master \ + WHERE type = 'index' AND name = 'notes_language'" + ), + 1 + ); + + // The migration is an index, not a rewrite: the note is untouched. + assert_eq!( + schema::notes::table + .select(schema::notes::language) + .first::(&mut connection) + .unwrap(), + "json" + ); +} + +#[test] +fn adopting_a_legacy_history_clears_the_pragma_it_replaces() { + let mut connection = legacy_database(&[LEGACY_SCHEMA], 1); + + run(&mut connection).unwrap(); + + // Two sources of truth on the schema state would drift apart; the + // migrations table is now the only one. + assert_eq!(user_version(&mut connection), 0); +} + +#[test] +fn a_migration_this_binary_does_not_know_is_refused() { + let mut connection = open_in_memory().unwrap(); + diesel::sql_query( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('2099-01-01-000000')", + ) + .execute(&mut connection) + .unwrap(); + + let error = run(&mut connection).unwrap_err(); + + // Reading a newer schema with older code would silently write rows the + // newer version cannot make sense of. + assert!(matches!(error, storage::StorageError::SchemaTooRecent(_))); +} diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs deleted file mode 100644 index 760881b..0000000 --- a/src-tauri/src/storage/mod.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! Persistance : SQLite embarqué (Diesel, `libsqlite3-sys` en `bundled`, base -//! dans `app_data_dir()`). Ouverture, configuration et migrations ici ; les -//! lectures et écritures dans `storage::notes` et `storage::spaces`, sous forme -//! de fonctions prenant une `&mut SqliteConnection` — d'où des tests sur base en -//! mémoire, sans lancer Tauri. **Aucune règle métier** : elles sont dans -//! `crate::domain`. -//! -//! ⚠️ **Les migrations sont append-only.** Elles vivent dans `src-tauri/migrations/`, -//! embarquées dans le binaire par [`embed_migrations!`] et suivies par la table -//! `__diesel_schema_migrations` : faire évoluer le modèle = ajouter un dossier -//! `AAAA-MM-JJ-HHMMSS_nom/`, jamais modifier une migration déjà livrée. -//! -//! Le `&mut` est imposé par Diesel, qui prend la connexion en exclusif à chaque -//! requête. Il ne change rien à la concurrence réelle : le mutex de [`Db`] la -//! sérialisait déjà. - -pub mod notes; -pub mod schema; -pub mod spaces; - -use std::fmt; -use std::path::Path; -use std::sync::Mutex; - -use chrono::{SecondsFormat, Utc}; -use diesel::connection::SimpleConnection; -use diesel::migration::MigrationSource; -use diesel::prelude::*; -use diesel::sql_types::{Integer, Text}; -use diesel::sqlite::Sqlite; -use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; - -/// Instant courant en ISO 8601 UTC, ex. `2026-07-25T09:12:00.000Z`. La -/// milliseconde n'est pas décorative : sans elle, deux notes modifiées dans la -/// même seconde seraient impossibles à départager au tri. -pub fn now_iso() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) -} - -/// Connexion unique partagée via `tauri::State` : `SqliteConnection` n'est pas -/// `Sync`, et deux commandes qui se chevauchent se sérialisent sur ce mutex. -pub type Db = Mutex; - -pub const DB_FILE_NAME: &str = "devbox.sqlite3"; - -const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); - -/// Nombre de migrations qu'a connues l'ancien versionnement par -/// `PRAGMA user_version` : sa valeur maximale livrée était 3. Voir -/// [`adopt_legacy_history`] — cette constante ne bouge plus, une migration -/// ajoutée aujourd'hui n'a jamais existé sous l'ancien schéma. -const LEGACY_MIGRATION_COUNT: usize = 3; - -/// Les commandes convertissent ces variantes en `AppError` : la variante devient -/// un **code** que le front traduit, et le `Display` ci-dessous n'est plus que -/// le détail technique — c'est pourquoi il peut rester en français. -#[derive(Debug)] -pub enum StorageError { - /// Jamais un `Ok` silencieux : le front croirait avoir enregistré. - NoteNotFound(String), - /// Espace visé inexistant : la note n'aurait nulle part où être rangée. - SpaceNotFound(String), - /// Nom déjà pris (comparaison insensible à la casse). - DuplicateSpaceName(String), - /// Base portant une migration que ce binaire ne connaît pas : elle a été - /// écrite par une version plus récente de l'application. - SchemaTooRecent(String), - /// Ouverture ou migration impossible — panne d'avant le premier `SELECT`. - Migration(String), - Sqlite(diesel::result::Error), -} - -impl fmt::Display for StorageError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NoteNotFound(id) => write!(f, "Note introuvable : {id}"), - Self::SpaceNotFound(id) => write!(f, "Espace introuvable : {id}"), - Self::DuplicateSpaceName(name) => { - write!(f, "Un espace nommé « {name} » existe déjà") - } - Self::SchemaTooRecent(version) => write!( - f, - "Base de données portant la migration « {version} », inconnue de cette version de DevBox", - ), - Self::Migration(detail) => write!(f, "Migration impossible : {detail}"), - Self::Sqlite(error) => write!(f, "Erreur de stockage : {error}"), - } - } -} - -impl std::error::Error for StorageError {} - -/// Requise par `Connection::transaction`, qui exige de savoir absorber l'erreur -/// de Diesel dans celle de l'appelant. -impl From for StorageError { - fn from(error: diesel::result::Error) -> Self { - Self::Sqlite(error) - } -} - -/// Ouvre la base (en la créant au besoin), la configure, migre. -pub fn open(path: &Path) -> Result { - let mut connection = SqliteConnection::establish(&path.to_string_lossy()) - .map_err(|error| StorageError::Migration(error.to_string()))?; - configure(&mut connection)?; - migrate(&mut connection)?; - Ok(connection) -} - -/// Base éphémère, pour les tests. -#[cfg(test)] -pub fn open_in_memory() -> Result { - let mut connection = SqliteConnection::establish(":memory:") - .map_err(|error| StorageError::Migration(error.to_string()))?; - configure(&mut connection)?; - migrate(&mut connection)?; - Ok(connection) -} - -fn configure(connection: &mut SqliteConnection) -> Result<(), StorageError> { - // ⚠️ `foreign_keys` se règle **par connexion** et est désactivé par défaut : - // sans lui les `ON DELETE CASCADE` sont inertes et les tags d'une note - // supprimée resteraient orphelins. WAL : un lecteur ne bloque plus un écrivain. - connection.batch_execute( - "PRAGMA foreign_keys = ON; - PRAGMA journal_mode = WAL;", - )?; - Ok(()) -} - -/// Versions des migrations embarquées, dans l'ordre d'application. -fn embedded_versions() -> Result, StorageError> { - let mut versions = MigrationSource::::migrations(&MIGRATIONS) - .map_err(|error| StorageError::Migration(error.to_string()))? - .iter() - .map(|migration| migration.name().version().to_string()) - .collect::>(); - versions.sort(); - - Ok(versions) -} - -#[derive(QueryableByName)] -struct UserVersion { - #[diesel(sql_type = Integer)] - user_version: i32, -} - -/// Fait adopter par Diesel l'historique qu'écrivait l'ancien `PRAGMA user_version`. -/// -/// Sans elle, une base déjà installée aurait un `__diesel_schema_migrations` vide -/// et rejouerait la migration initiale sur des tables existantes — échec au -/// lancement. Les `n` premières migrations sont donc marquées comme appliquées -/// sans être exécutées. -/// -/// Le pragma est ensuite remis à zéro : deux sources de vérité sur l'état du -/// schéma finiraient par diverger. Un binaire antérieur à Diesel rouvrant cette -/// base tenterait alors de rejouer la migration initiale et échouerait au -/// lancement — bruyamment, plutôt que d'écrire dans un schéma qu'il croit à jour. -fn adopt_legacy_history( - connection: &mut SqliteConnection, - embedded: &[String], -) -> Result<(), StorageError> { - let legacy: i32 = diesel::sql_query("PRAGMA user_version") - .get_result::(connection)? - .user_version; - - // Zéro : base neuve, ou passée par ici lors d'une ouverture précédente. - if legacy <= 0 { - return Ok(()); - } - - // Crée `__diesel_schema_migrations` si elle manque — l'insertion suit. - connection - .applied_migrations() - .map_err(|error| StorageError::Migration(error.to_string()))?; - - let adopted = (legacy as usize) - .min(LEGACY_MIGRATION_COUNT) - .min(embedded.len()); - - connection.transaction(|connection| { - for version in &embedded[..adopted] { - diesel::sql_query( - "INSERT OR IGNORE INTO __diesel_schema_migrations (version) VALUES (?)", - ) - .bind::(version) - .execute(connection)?; - } - diesel::sql_query("PRAGMA user_version = 0").execute(connection)?; - - Ok::<_, StorageError>(()) - }) -} - -fn migrate(connection: &mut SqliteConnection) -> Result<(), StorageError> { - let embedded = embedded_versions()?; - - adopt_legacy_history(connection, &embedded)?; - - // Refuser franchement vaut mieux que lire de travers et écraser des données : - // une migration appliquée qu'on ne connaît pas signale une base écrite par - // une version plus récente. - let applied = connection - .applied_migrations() - .map_err(|error| StorageError::Migration(error.to_string()))?; - if let Some(unknown) = applied - .iter() - .map(ToString::to_string) - .find(|version| !embedded.contains(version)) - { - return Err(StorageError::SchemaTooRecent(unknown)); - } - - connection - .run_pending_migrations(MIGRATIONS) - .map_err(|error| StorageError::Migration(error.to_string()))?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use diesel::sql_types::BigInt; - - /// Le SQL de la migration initiale tel qu'il a été livré. Rejoué à la main, - /// il fabrique une base « héritée » : schéma en place, `user_version` posé, - /// aucune trace côté Diesel. - const LEGACY_SCHEMA: &str = include_str!("../../migrations/2026-07-25-000001_initial/up.sql"); - const LEGACY_FOLD_TAG_CASE: &str = - include_str!("../../migrations/2026-07-25-000002_fold_tag_case/up.sql"); - - #[derive(QueryableByName)] - struct Count { - #[diesel(sql_type = BigInt)] - count: i64, - } - - fn count(connection: &mut SqliteConnection, query: &str) -> i64 { - diesel::sql_query(query) - .get_result::(connection) - .unwrap() - .count - } - - fn user_version(connection: &mut SqliteConnection) -> i32 { - diesel::sql_query("PRAGMA user_version") - .get_result::(connection) - .unwrap() - .user_version - } - - /// Base au schéma d'origine, versionnée comme l'ancien code le faisait. - fn legacy_database(sql: &[&str], version: i32) -> SqliteConnection { - let mut connection = SqliteConnection::establish(":memory:").unwrap(); - configure(&mut connection).unwrap(); - for statements in sql { - connection.batch_execute(statements).unwrap(); - } - connection - .batch_execute(&format!("PRAGMA user_version = {version}")) - .unwrap(); - - connection - } - - #[test] - fn opening_twice_is_idempotent() { - let directory = std::env::temp_dir().join(format!("devbox-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&directory).unwrap(); - let path = directory.join(DB_FILE_NAME); - - open(&path).unwrap(); - // A second open must find every migration already applied and not - // attempt to re-create the tables. - let mut connection = open(&path).unwrap(); - - assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); - - std::fs::remove_dir_all(&directory).ok(); - } - - #[test] - fn a_fresh_database_applies_every_embedded_migration() { - let mut connection = open_in_memory().unwrap(); - - let applied = connection.applied_migrations().unwrap(); - assert_eq!(applied.len(), embedded_versions().unwrap().len()); - } - - #[test] - fn a_v1_database_upgrades_and_folds_tag_case() { - let mut connection = legacy_database( - &[ - LEGACY_SCHEMA, - "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); - INSERT INTO notes VALUES - ('n-1', 's-1', 'A', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', - '2026-07-25T09:00:00.000Z', 'permanent', NULL), - ('n-2', 's-1', 'B', 'txt', '', '', 0, '2026-07-25T09:00:00.000Z', - '2026-07-25T09:00:00.000Z', 'permanent', NULL); - INSERT INTO note_tags VALUES ('n-1', 'Urgent'), ('n-2', 'urgent');", - ], - 1, - ); - - // Passing at all is half the assertion: replaying the initial migration - // on these tables would fail on `CREATE TABLE spaces`. - migrate(&mut connection).unwrap(); - - assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); - - // Both rows survive: the collation folds the facet, it does not drop data. - assert_eq!( - count(&mut connection, "SELECT COUNT(*) AS count FROM note_tags"), - 2 - ); - - // But the rail now sees one tag where it used to see two. - assert_eq!( - count( - &mut connection, - "SELECT COUNT(*) AS count FROM (SELECT DISTINCT tag FROM note_tags)" - ), - 1 - ); - } - - #[test] - fn a_v2_database_gains_the_language_index_without_touching_its_notes() { - let mut connection = legacy_database( - &[ - LEGACY_SCHEMA, - LEGACY_FOLD_TAG_CASE, - "INSERT INTO spaces (id, name) VALUES ('s-1', 'Perso'); - INSERT INTO notes VALUES - ('n-1', 's-1', 'A', 'json', '', '', 0, '2026-07-25T09:00:00.000Z', - '2026-07-25T09:00:00.000Z', 'permanent', NULL);", - ], - 2, - ); - - migrate(&mut connection).unwrap(); - - assert!(!connection.has_pending_migration(MIGRATIONS).unwrap()); - assert_eq!( - count( - &mut connection, - "SELECT COUNT(*) AS count FROM sqlite_master \ - WHERE type = 'index' AND name = 'notes_language'" - ), - 1 - ); - - // The migration is an index, not a rewrite: the note is untouched. - assert_eq!( - schema::notes::table - .select(schema::notes::language) - .first::(&mut connection) - .unwrap(), - "json" - ); - } - - #[test] - fn adopting_a_legacy_history_clears_the_pragma_it_replaces() { - let mut connection = legacy_database(&[LEGACY_SCHEMA], 1); - - migrate(&mut connection).unwrap(); - - // Two sources of truth on the schema state would drift apart; the - // migrations table is now the only one. - assert_eq!(user_version(&mut connection), 0); - } - - #[test] - fn a_migration_this_binary_does_not_know_is_refused() { - let mut connection = open_in_memory().unwrap(); - diesel::sql_query( - "INSERT INTO __diesel_schema_migrations (version) VALUES ('2099-01-01-000000')", - ) - .execute(&mut connection) - .unwrap(); - - let error = migrate(&mut connection).unwrap_err(); - - // Reading a newer schema with older code would silently write rows the - // newer version cannot make sense of. - assert!(matches!(error, StorageError::SchemaTooRecent(_))); - } - - #[test] - fn a_fresh_database_is_empty() { - let mut connection = open_in_memory().unwrap(); - - assert!(notes::list(&mut connection).unwrap().is_empty()); - assert!(spaces::list(&mut connection).unwrap().is_empty()); - } - - #[test] - fn foreign_keys_are_enforced() { - let mut connection = open_in_memory().unwrap(); - - #[derive(QueryableByName)] - struct ForeignKeys { - #[diesel(sql_type = Integer)] - foreign_keys: i32, - } - - let enabled = diesel::sql_query("PRAGMA foreign_keys") - .get_result::(&mut connection) - .unwrap() - .foreign_keys; - - assert_eq!(enabled, 1); - } -} diff --git a/src-tauri/src/storage/notes.rs b/src-tauri/src/storage/notes.rs index 5d91469..27f17c9 100644 --- a/src-tauri/src/storage/notes.rs +++ b/src-tauri/src/storage/notes.rs @@ -1,27 +1,24 @@ //! Lecture et écriture des notes : **du SQL, et rien d'autre**. //! -//! Ne descendent dans le `WHERE` que les critères indexés par SQLite — espace, -//! épinglage, cycle de vie, langage, tag. Correspondance de recherche, -//! regroupement en sections et normalisation des tags sont des règles, et vivent -//! dans `crate::domain`. -//! -//! `now` est passé en paramètre plutôt que lu de l'horloge : les écritures -//! restent reproductibles en test. +//! Ne descend dans le `WHERE` que ce que SQLite indexe. Recherche texte, +//! sections et normalisation des tags sont des règles : `crate::domain`. use std::collections::HashMap; use diesel::prelude::*; use uuid::Uuid; +use chrono::{DateTime, Utc}; + use super::schema::{note_tags, notes}; use super::{StorageError, spaces}; +use crate::domain::iso8601; use crate::domain::note::{Note, NoteDraft, NoteLifecycle, NotePatch}; +use crate::domain::tag; use crate::domain::view::{Facets, NoteFilter, NotesQuery}; -use crate::domain::{detect, rules}; -/// Forme tabulaire d'une note : le `lifecycle` du domaine y est éclaté en deux -/// colonnes, et les tags en sont absents — ils vivent dans `note_tags` et sont -/// rattachés ensuite, en une requête pour toute la liste. +/// `lifecycle` y est éclaté en deux colonnes, et les tags en sont absents : ils +/// vivent dans `note_tags`, rattachés ensuite en une requête pour toute la liste. #[derive(Queryable, Selectable, Insertable)] #[diesel(table_name = notes)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] @@ -39,55 +36,73 @@ struct NoteRow { lifecycle_expires_at: Option, } -impl From for Note { - fn from(row: NoteRow) -> Self { +/// Une date illisible fait **échouer la lecture** : ces colonnes ne sont écrites +/// que par [`iso8601::format`], donc une valeur hors format signale une base +/// corrompue, et deviner y rangerait la note à une date arbitraire sans rien dire. +/// +/// Le langage, lui, se replie sur son défaut : `notes.language` ne porte aucun +/// `CHECK` (migration 3), une version plus récente peut y avoir écrit un langage +/// légitime qu'ignore celle-ci. La note reste lisible, sans sa coloration. +impl TryFrom for Note { + type Error = StorageError; + + fn try_from(row: NoteRow) -> Result { + let instant = |field: &'static str, value: &str| { + iso8601::parse(value).map_err(|_| StorageError::CorruptRow { + id: row.id.clone(), + field, + }) + }; + // Le `CHECK` du schéma rend `("expires", None)` inatteignable. - let lifecycle = match (row.lifecycle_kind.as_str(), row.lifecycle_expires_at) { - ("expires", Some(at)) => NoteLifecycle::Expires { at }, + let lifecycle = match (row.lifecycle_kind.as_str(), &row.lifecycle_expires_at) { + ("expires", Some(at)) => NoteLifecycle::Expires { + at: instant("lifecycleExpiresAt", at)?, + }, _ => NoteLifecycle::Permanent, }; - Self { + Ok(Self { + created_at: instant("createdAt", &row.created_at)?, + updated_at: instant("updatedAt", &row.updated_at)?, + language: row.language.parse().unwrap_or_default(), id: row.id, space_id: row.space_id, title: row.title, - language: row.language, content: row.content, source: row.source, tags: Vec::new(), pinned: row.pinned, - created_at: row.created_at, - updated_at: row.updated_at, lifecycle, - } + }) } } impl From<&Note> for NoteRow { fn from(note: &Note) -> Self { - let (lifecycle_kind, lifecycle_expires_at) = match ¬e.lifecycle { + let (lifecycle_kind, lifecycle_expires_at) = match note.lifecycle { NoteLifecycle::Permanent => ("permanent", None), - NoteLifecycle::Expires { at } => ("expires", Some(at.clone())), + NoteLifecycle::Expires { at } => ("expires", Some(iso8601::format(at))), }; Self { id: note.id.clone(), space_id: note.space_id.clone(), title: note.title.clone(), - language: note.language.clone(), + language: note.language.to_string(), content: note.content.clone(), source: note.source.clone(), pinned: note.pinned, - created_at: note.created_at.clone(), - updated_at: note.updated_at.clone(), + created_at: iso8601::format(note.created_at), + updated_at: iso8601::format(note.updated_at), lifecycle_kind: lifecycle_kind.to_string(), lifecycle_expires_at, } } } -/// Tags de toutes les notes en une requête ; une par note coûterait cher dès -/// quelques centaines. +/// Une requête pour toute la liste : une par note coûterait cher dès quelques +/// centaines. fn all_tags( connection: &mut SqliteConnection, ) -> Result>, StorageError> { @@ -112,23 +127,21 @@ fn tags_of(connection: &mut SqliteConnection, note_id: &str) -> Result(connection)?) } -/// Remplace intégralement les tags, uniquement quand le patch en porte. +/// Écrit les tags **déjà normalisés** par le domaine, et renvoie ce que la +/// relecture donne. /// -/// Renvoie les tags **réellement écrits**, qui peuvent différer de ceux reçus : -/// l'appelant doit adopter cette valeur, sinon il rendrait au front une note qui -/// ne correspond pas à la base. Le tri final aligne l'écriture sur la lecture, -/// sans quoi les tags se réordonneraient au rechargement suivant. +/// ⚠️ Relus plutôt que triés ici : `note_tags.tag` est `COLLATE NOCASE` et la +/// lecture ordonne dans cette collation, qu'un `sort()` en octets ne reproduit +/// pas — `Urgent` passerait avant `auth` à l'écriture et après au rechargement. fn replace_tags( connection: &mut SqliteConnection, note_id: &str, - requested: &[String], + tags: &[String], ) -> Result, StorageError> { - let mut normalized = rules::normalize_tags(requested); - diesel::delete(note_tags::table.filter(note_tags::note_id.eq(note_id))).execute(connection)?; - if !normalized.is_empty() { - let rows: Vec<_> = normalized + if !tags.is_empty() { + let rows: Vec<_> = tags .iter() .map(|tag| (note_tags::note_id.eq(note_id), note_tags::tag.eq(tag))) .collect(); @@ -137,37 +150,14 @@ fn replace_tags( .execute(connection)?; } - normalized.sort(); - - Ok(normalized) + tags_of(connection, note_id) } -/// **Réservée aux tests** : en production tout passe par [`fetch`] puis -/// `domain::view::build`. -#[cfg(test)] -pub fn list(connection: &mut SqliteConnection) -> Result, StorageError> { - fetch( - connection, - &NotesQuery { - space_id: None, - search: String::new(), - filter: NoteFilter::All, - tags: Vec::new(), - languages: Vec::new(), - now: "2026-07-25T09:00:00.000Z".to_string(), - tz_offset_minutes: 0, - }, - ) - .map(|(notes, _)| notes) -} - -/// Facettes proposables par les rails, portées à l'espace et non au filtre -/// courant : ne proposer que celles des notes déjà filtrées viderait les rails -/// dès la première sélection, rendant impossible d'en choisir une seconde. +/// Portées à l'espace et non au filtre courant — voir [`NotesView`]. /// /// La jointure sur `notes` est inconditionnelle : toute ligne de `note_tags` -/// pointe une note existante (clé étrangère), elle n'ajoute donc ni ne retire -/// aucun tag quand aucun espace n'est actif. +/// pointe une note existante, elle n'ajoute ni ne retire donc rien quand aucun +/// espace n'est actif. fn facets( connection: &mut SqliteConnection, space_id: Option<&str>, @@ -191,12 +181,18 @@ fn facets( Ok(Facets { tags: tags.load::(connection)?, - languages: languages.load::(connection)?, + // Un langage stocké qu'on ne connaît pas n'a pas de facette à proposer : + // le rail ne peut pas offrir un filtre que le front ne sait pas nommer. + languages: languages + .load::(connection)? + .iter() + .filter_map(|language| language.parse().ok()) + .collect(), }) } -/// Notes retenues par les critères **grossiers**, et facettes des rails. -/// `domain::view::build` prend le relais pour la recherche texte et les sections. +/// Critères **grossiers** seulement ; `domain::view::build` prend le relais pour +/// la recherche texte et les sections. pub fn fetch( connection: &mut SqliteConnection, request: &NotesQuery, @@ -213,18 +209,15 @@ pub fn fetch( NoteFilter::Untriaged => query = query.filter(notes::lifecycle_kind.eq("expires")), } - // Pas de normalisation, contrairement aux tags : un langage est choisi dans - // une liste fermée, et une valeur inconnue ne correspond à rien — c'est le - // résultat attendu. if !request.languages.is_empty() { // Union, comme les tags : sélectionner JSON puis YAML montre les deux. - query = query.filter(notes::language.eq_any(request.languages.clone())); + let selected: Vec = request.languages.iter().map(ToString::to_string).collect(); + query = query.filter(notes::language.eq_any(selected)); } // Même normalisation qu'à l'écriture, sinon un `#urgent` saisi au clavier ne - // retrouverait pas le tag `urgent` stocké. La comparaison qui suit se fait - // dans la collation de `note_tags.tag`, donc `NOCASE`. - let selected_tags = rules::normalize_tags(&request.tags); + // retrouverait pas le `urgent` stocké. + let selected_tags = tag::normalize(&request.tags); if !selected_tags.is_empty() { // « au moins un tag », pas « tous » : comportement d'un rail de facettes. query = query.filter( @@ -236,18 +229,17 @@ pub fn fetch( ); } - // Tri décidé ici une fois pour toutes ; le front conserve l'ordre reçu. - // Sur `updated_at` alors que les sections regroupent sur `created_at` : - // la section dit quand la note est née, l'ordre interne laquelle a été - // touchée en dernier. + // Sur `updated_at` alors que les sections regroupent sur `created_at` : la + // section dit quand la note est née, l'ordre interne laquelle a bougé en + // dernier. Le front conserve l'ordre reçu. let mut notes = query .order((notes::updated_at.desc(), notes::id.asc())) .load::(connection)? .into_iter() - .map(Note::from) - .collect::>(); + .map(Note::try_from) + .collect::, _>>()?; - // Rattachés avant de rendre la main : la recherche du domaine porte dessus. + // La recherche du domaine porte dessus. let mut grouped = all_tags(connection)?; for note in &mut notes { note.tags = grouped.remove(¬e.id).unwrap_or_default(); @@ -268,98 +260,60 @@ fn find(connection: &mut SqliteConnection, id: &str) -> Result, Sto Ok(Some(Note { tags: tags_of(connection, id)?, - ..Note::from(row) + ..Note::try_from(row)? })) } -/// Renvoie la version persistée — identifiant définitif et horodatages compris. -/// Le front adopte cette valeur telle quelle. +/// Renvoie la version persistée — identifiant et horodatages compris. Le front +/// adopte cette valeur telle quelle. pub fn create( connection: &mut SqliteConnection, - draft: &NoteDraft, - now: &str, + draft: NoteDraft, + now: DateTime, ) -> Result { connection.transaction(|connection| { if !spaces::exists(connection, &draft.space_id)? { - return Err(StorageError::SpaceNotFound(draft.space_id.clone())); + return Err(StorageError::SpaceNotFound(draft.space_id)); } - let mut note = Note { - id: Uuid::new_v4().to_string(), - space_id: draft.space_id.clone(), - title: draft.title.clone(), - language: draft.language.clone(), - content: draft.content.clone(), - source: draft.source.clone(), - tags: draft.tags.clone(), - pinned: draft.pinned, - created_at: now.to_string(), - updated_at: now.to_string(), - lifecycle: draft.lifecycle.clone(), - }; + let mut note = draft.into_note(Uuid::new_v4().to_string(), now); diesel::insert_into(notes::table) .values(NoteRow::from(¬e)) .execute(connection)?; - // Les tags écrits sont normalisés, pas ceux du brouillon. - note.tags = replace_tags(connection, ¬e.id, &draft.tags)?; + let written = std::mem::take(&mut note.tags); + note.tags = replace_tags(connection, ¬e.id, &written)?; Ok(note) }) } -/// Applique **uniquement** les champs renseignés du patch et rafraîchit -/// `updated_at`. Un `None` signifie « ne pas toucher » — d'où le -/// lire-modifier-écrire, en transaction pour qu'aucune commande ne s'intercale. +/// Lit, applique le patch, réécrit — en transaction pour qu'aucune commande ne +/// s'intercale. La fusion est une règle et vit dans [`NotePatch::apply`]. /// /// Identifiant inconnu ⇒ `Err` : le front croirait sinon avoir enregistré. pub fn update( connection: &mut SqliteConnection, id: &str, patch: &NotePatch, - now: &str, + now: DateTime, ) -> Result { connection.transaction(|connection| { let Some(mut note) = find(connection, id)? else { return Err(StorageError::NoteNotFound(id.to_string())); }; - // Décidé sur la note **avant** patch : c'est son état d'origine qui dit si - // elle reçoit là son premier contenu. La règle est dans le domaine, comme - // `normalize_tags` ; ici on ne fait que l'appliquer. - let detected = detect::language_after_patch(¬e, patch); - - if let Some(space_id) = &patch.space_id { - if !spaces::exists(connection, space_id)? { - return Err(StorageError::SpaceNotFound(space_id.clone())); - } - note.space_id = space_id.clone(); - } - if let Some(title) = &patch.title { - note.title = title.clone(); - } - if let Some(language) = &patch.language { - note.language = language.clone(); + // Seule vérification que le domaine ne peut pas faire : elle demande la base. + if let Some(space_id) = &patch.space_id + && !spaces::exists(connection, space_id)? + { + return Err(StorageError::SpaceNotFound(space_id.clone())); } - if let Some(content) = &patch.content { - note.content = content.clone(); - } - if let Some(language) = detected { - note.language = language; - } - if let Some(source) = &patch.source { - note.source = source.clone(); - } - if let Some(pinned) = patch.pinned { - note.pinned = pinned; - } - if let Some(lifecycle) = &patch.lifecycle { - note.lifecycle = lifecycle.clone(); - } - note.updated_at = now.to_string(); - // Colonnes énumérées plutôt qu'un `AsChangeset` sur `NoteRow` : celui-ci - // réécrirait aussi `created_at`, que rien ici n'a le droit de bouger. + patch.apply(&mut note, now); + + // Colonnes énumérées plutôt qu'un `AsChangeset` : celui-ci réécrirait + // aussi `created_at`, que rien ici n'a le droit de bouger. let row = NoteRow::from(¬e); diesel::update(notes::table.find(¬e.id)) .set(( @@ -375,16 +329,17 @@ pub fn update( )) .execute(connection)?; - if let Some(tags) = &patch.tags { - note.tags = replace_tags(connection, ¬e.id, tags)?; + if patch.tags.is_some() { + let written = std::mem::take(&mut note.tags); + note.tags = replace_tags(connection, ¬e.id, &written)?; } Ok(note) }) } -/// Ses tags partent par cascade (d'où le `PRAGMA foreign_keys = ON` de -/// `storage::configure`). Identifiant inconnu ⇒ `Err`. +/// Ses tags partent par cascade — d'où le `PRAGMA foreign_keys` de +/// `storage::configure`. Identifiant inconnu ⇒ `Err`. pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), StorageError> { let deleted = diesel::delete(notes::table.find(id)).execute(connection)?; @@ -394,1007 +349,3 @@ pub fn delete(connection: &mut SqliteConnection, id: &str) -> Result<(), Storage Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::view; - use crate::domain::view::NotesView; - use crate::storage::open_in_memory; - use crate::storage::schema::spaces as spaces_table; - - const T0: &str = "2026-07-25T09:00:00.000Z"; - const T1: &str = "2026-07-25T10:00:00.000Z"; - - /// Chemin de lecture complet — SQL puis règles — tel que `query_notes` - /// l'assemble. Les règles ont leurs propres tests dans `domain/` ; ici on - /// vérifie qu'elles s'appliquent bien à ce que la base a réellement rendu. - fn query( - connection: &mut SqliteConnection, - request: &NotesQuery, - ) -> Result { - let (notes, facets) = fetch(connection, request)?; - Ok(view::build(notes, facets, request).expect("les tests fournissent un instant valide")) - } - - fn space(connection: &mut SqliteConnection, name: &str) -> String { - spaces::create(connection, name).unwrap().id - } - - fn draft(space_id: &str) -> NoteDraft { - NoteDraft { - space_id: space_id.to_string(), - title: "Titre".to_string(), - language: "txt".to_string(), - content: "Contenu".to_string(), - source: "API Gateway / Auth".to_string(), - tags: vec!["auth".to_string(), "api".to_string()], - pinned: false, - lifecycle: NoteLifecycle::Permanent, - } - } - - #[test] - fn a_created_note_is_read_back_whole() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - let listed = list(&mut connection).unwrap(); - - assert_eq!(listed.len(), 1); - let note = &listed[0]; - assert_eq!(note.id, created.id); - assert_eq!(note.space_id, space_id); - assert_eq!(note.title, "Titre"); - assert_eq!(note.content, "Contenu"); - assert_eq!(note.source, "API Gateway / Auth"); - assert!(!note.pinned); - // Tags come back sorted, not in insertion order. - assert_eq!(note.tags, ["api", "auth"]); - } - - #[test] - fn creation_stamps_both_dates_identically() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - assert_eq!(created.created_at, T0); - assert_eq!(created.updated_at, T0); - } - - #[test] - fn an_expiring_lifecycle_survives_a_round_trip() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let expiring = NoteDraft { - lifecycle: NoteLifecycle::Expires { - at: "2026-08-01T00:00:00.000Z".to_string(), - }, - ..draft(&space_id) - }; - - create(&mut connection, &expiring, T0).unwrap(); - - let listed = list(&mut connection).unwrap(); - assert!(matches!( - &listed[0].lifecycle, - NoteLifecycle::Expires { at } if at == "2026-08-01T00:00:00.000Z" - )); - } - - #[test] - fn dropping_an_expiry_clears_the_stored_date() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let expiring = NoteDraft { - lifecycle: NoteLifecycle::Expires { - at: "2026-08-01T00:00:00.000Z".to_string(), - }, - ..draft(&space_id) - }; - let created = create(&mut connection, &expiring, T0).unwrap(); - - let patch = NotePatch { - lifecycle: Some(NoteLifecycle::Permanent), - ..NotePatch::default() - }; - update(&mut connection, &created.id, &patch, T1).unwrap(); - - // The schema's CHECK ties the two columns together: leaving the date - // behind would make the write fail outright. - assert!(matches!( - list(&mut connection).unwrap()[0].lifecycle, - NoteLifecycle::Permanent - )); - } - - #[test] - fn creating_in_an_unknown_space_is_refused() { - let mut connection = open_in_memory().unwrap(); - - let error = create(&mut connection, &draft("inconnu"), T0).unwrap_err(); - - assert!(matches!(error, StorageError::SpaceNotFound(_))); - assert!(list(&mut connection).unwrap().is_empty()); - } - - #[test] - fn notes_are_listed_most_recently_updated_first() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - - let older = create(&mut connection, &draft(&space_id), T0).unwrap(); - let newer = create(&mut connection, &draft(&space_id), T1).unwrap(); - - let ids: Vec = list(&mut connection) - .unwrap() - .into_iter() - .map(|n| n.id) - .collect(); - - assert_eq!(ids, [newer.id, older.id]); - } - - #[test] - fn an_absent_patch_field_leaves_the_stored_value_untouched() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - let patch = NotePatch { - title: Some("Nouveau titre".to_string()), - ..NotePatch::default() - }; - let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); - - assert_eq!(updated.title, "Nouveau titre"); - // Everything the patch did not mention must survive, tags included. - assert_eq!(updated.content, "Contenu"); - assert_eq!(updated.source, "API Gateway / Auth"); - assert_eq!(updated.tags, ["api", "auth"]); - assert!(matches!(updated.lifecycle, NoteLifecycle::Permanent)); - } - - #[test] - fn updating_refreshes_updated_at_but_not_created_at() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - let updated = update(&mut connection, &created.id, &NotePatch::default(), T1).unwrap(); - - assert_eq!(updated.created_at, T0); - assert_eq!(updated.updated_at, T1); - } - - #[test] - fn pasting_into_a_freshly_created_note_settles_its_language() { - // End to end for the ordinary gesture: create empty, then paste. The - // rule is tested in `domain::detect`; here we check it actually reaches - // the stored row. - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let empty = NoteDraft { - language: "txt".to_string(), - content: String::new(), - ..draft(&space_id) - }; - let created = create(&mut connection, &empty, T0).unwrap(); - - let patch = NotePatch { - content: Some("interface Note { id: string }".to_string()), - ..NotePatch::default() - }; - let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); - - assert_eq!(updated.language, "ts"); - assert_eq!(list(&mut connection).unwrap()[0].language, "ts"); - } - - #[test] - fn a_later_edit_does_not_move_the_language_again() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let empty = NoteDraft { - language: "txt".to_string(), - content: String::new(), - ..draft(&space_id) - }; - let created = create(&mut connection, &empty, T0).unwrap(); - - let first = NotePatch { - content: Some("SELECT 1".to_string()), - ..NotePatch::default() - }; - update(&mut connection, &created.id, &first, T1).unwrap(); - - let second = NotePatch { - content: Some("interface Note { id: string }".to_string()), - ..NotePatch::default() - }; - let updated = update(&mut connection, &created.id, &second, T1).unwrap(); - - // The note had an identity by then; only the first content decides. - assert_eq!(updated.language, "sql"); - } - - #[test] - fn patching_tags_replaces_the_whole_set() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - let patch = NotePatch { - tags: Some(vec!["sql".to_string()]), - ..NotePatch::default() - }; - update(&mut connection, &created.id, &patch, T1).unwrap(); - - assert_eq!(list(&mut connection).unwrap()[0].tags, ["sql"]); - } - - #[test] - fn patching_tags_to_an_empty_list_clears_them() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - let patch = NotePatch { - tags: Some(Vec::new()), - ..NotePatch::default() - }; - let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); - - assert!(updated.tags.is_empty()); - assert!(list(&mut connection).unwrap()[0].tags.is_empty()); - } - - #[test] - fn a_note_can_be_moved_to_another_space() { - let mut connection = open_in_memory().unwrap(); - let origin = space(&mut connection, "Perso"); - let destination = space(&mut connection, "Boulot"); - let created = create(&mut connection, &draft(&origin), T0).unwrap(); - - let patch = NotePatch { - space_id: Some(destination.clone()), - ..NotePatch::default() - }; - let updated = update(&mut connection, &created.id, &patch, T1).unwrap(); - - assert_eq!(updated.space_id, destination); - } - - #[test] - fn moving_a_note_to_an_unknown_space_is_refused_and_changes_nothing() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - let patch = NotePatch { - space_id: Some("inconnu".to_string()), - title: Some("Ne doit pas passer".to_string()), - ..NotePatch::default() - }; - let error = update(&mut connection, &created.id, &patch, T1).unwrap_err(); - - assert!(matches!(error, StorageError::SpaceNotFound(_))); - let note = &list(&mut connection).unwrap()[0]; - assert_eq!(note.space_id, space_id); - assert_eq!(note.title, "Titre"); - } - - #[test] - fn updating_an_unknown_note_reports_an_error() { - let mut connection = open_in_memory().unwrap(); - - let error = update(&mut connection, "inconnu", &NotePatch::default(), T1).unwrap_err(); - - assert!(matches!(error, StorageError::NoteNotFound(_))); - } - - #[test] - fn deleting_removes_the_note_and_its_tags() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let created = create(&mut connection, &draft(&space_id), T0).unwrap(); - - delete(&mut connection, &created.id).unwrap(); - - assert!(list(&mut connection).unwrap().is_empty()); - let orphan_tags = note_tags::table - .count() - .get_result::(&mut connection) - .unwrap(); - assert_eq!(orphan_tags, 0); - } - - #[test] - fn deleting_an_unknown_note_reports_an_error() { - let mut connection = open_in_memory().unwrap(); - - let error = delete(&mut connection, "inconnu").unwrap_err(); - - assert!(matches!(error, StorageError::NoteNotFound(_))); - } - - /// Neutral query: everything, no search, no tags. Tests override one field - /// at a time so each one states exactly what it exercises. - fn all_notes() -> NotesQuery { - NotesQuery { - space_id: None, - search: String::new(), - filter: NoteFilter::All, - tags: Vec::new(), - languages: Vec::new(), - now: T1.to_string(), - tz_offset_minutes: 0, - } - } - - fn matched_ids(view: &NotesView) -> Vec { - view.sections - .iter() - .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) - .collect() - } - - fn tagged(space_id: &str, tags: &[&str]) -> NoteDraft { - NoteDraft { - tags: tags.iter().map(|tag| tag.to_string()).collect(), - ..draft(space_id) - } - } - - fn written_in(space_id: &str, language: &str) -> NoteDraft { - NoteDraft { - language: language.to_string(), - ..draft(space_id) - } - } - - #[test] - fn a_query_without_criteria_returns_every_note() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - assert_eq!(view.matched, 1); - assert!(!view.is_filtering); - } - - #[test] - fn the_space_filter_excludes_the_other_spaces() { - let mut connection = open_in_memory().unwrap(); - let here = space(&mut connection, "Perso"); - let elsewhere = space(&mut connection, "Boulot"); - let kept = create(&mut connection, &draft(&here), T0).unwrap(); - create(&mut connection, &draft(&elsewhere), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - space_id: Some(here), - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(matched_ids(&view), [kept.id]); - } - - #[test] - fn no_space_means_every_space_rather_than_none() { - let mut connection = open_in_memory().unwrap(); - let here = space(&mut connection, "Perso"); - let elsewhere = space(&mut connection, "Boulot"); - create(&mut connection, &draft(&here), T0).unwrap(); - create(&mut connection, &draft(&elsewhere), T0).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - assert_eq!(view.matched, 2); - } - - #[test] - fn the_pinned_filter_keeps_only_pinned_notes() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let pinned = create( - &mut connection, - &NoteDraft { - pinned: true, - ..draft(&space_id) - }, - T0, - ) - .unwrap(); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - filter: NoteFilter::Pinned, - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(matched_ids(&view), [pinned.id]); - } - - #[test] - fn the_untriaged_filter_keeps_only_expiring_notes() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let expiring = create( - &mut connection, - &NoteDraft { - lifecycle: NoteLifecycle::Expires { - at: "2026-08-01T00:00:00.000Z".to_string(), - }, - ..draft(&space_id) - }, - T0, - ) - .unwrap(); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - filter: NoteFilter::Untriaged, - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(matched_ids(&view), [expiring.id]); - } - - #[test] - fn a_quick_filter_alone_does_not_switch_to_results_mode() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - filter: NoteFilter::Pinned, - ..all_notes() - }, - ) - .unwrap(); - - // Pinned/untriaged narrow a view that stays chronological; only a search - // or a tag selection collapses it into a flat result list. - assert!(!view.is_filtering); - } - - #[test] - fn the_search_matches_the_title_the_content_and_the_tags() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let by_title = create( - &mut connection, - &NoteDraft { - title: "Script de deploiement".to_string(), - content: String::new(), - tags: Vec::new(), - ..draft(&space_id) - }, - T0, - ) - .unwrap(); - let by_content = create( - &mut connection, - &NoteDraft { - title: String::new(), - content: "kubectl rollout".to_string(), - tags: Vec::new(), - ..draft(&space_id) - }, - T0, - ) - .unwrap(); - let by_tag = create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); - - for (needle, expected) in [ - ("deploiement", &by_title), - ("rollout", &by_content), - ("urgent", &by_tag), - ] { - let view = query( - &mut connection, - &NotesQuery { - search: needle.to_string(), - ..all_notes() - }, - ) - .unwrap(); - assert_eq!( - matched_ids(&view), - [expected.id.as_str()], - "needle: {needle}" - ); - } - } - - #[test] - fn the_search_ignores_case_beyond_ascii() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create( - &mut connection, - &NoteDraft { - title: "Étape de migration".to_string(), - ..draft(&space_id) - }, - T0, - ) - .unwrap(); - - // SQLite's LOWER() only folds ASCII, so "É" would never match "é" if the - // search were pushed into SQL. This is why it is done in Rust. - let view = query( - &mut connection, - &NotesQuery { - search: "étape".to_string(), - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(view.matched, 1); - } - - #[test] - fn a_blank_search_is_not_a_search() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - search: " ".to_string(), - ..all_notes() - }, - ) - .unwrap(); - - assert!(!view.is_filtering); - assert_eq!(view.matched, 1); - } - - #[test] - fn a_note_matches_when_it_carries_at_least_one_selected_tag() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let one = create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); - let two = create(&mut connection, &tagged(&space_id, &["later"]), T0).unwrap(); - create(&mut connection, &tagged(&space_id, &["neither"]), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - tags: vec!["urgent".to_string(), "later".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - // A facet rail is a union, not an intersection: requiring every tag - // would make a second selection almost always empty. - let ids = matched_ids(&view); - assert_eq!(ids.len(), 2); - assert!(ids.contains(&one.id) && ids.contains(&two.id)); - assert!(view.is_filtering); - } - - #[test] - fn a_selected_tag_is_normalised_like_a_stored_one() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - tags: vec![" #urgent ".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(view.matched, 1); - } - - #[test] - fn a_selected_tag_matches_a_stored_one_of_a_different_case() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &tagged(&space_id, &["Urgent"]), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - tags: vec!["urgent".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - // Without COLLATE NOCASE on note_tags.tag the IN (…) comparison runs in - // BINARY and misses: the rail would offer a facet selecting nothing. - assert_eq!(view.matched, 1); - } - - #[test] - fn the_rail_offers_one_facet_for_tags_differing_only_in_case() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &tagged(&space_id, &["Urgent"]), T0).unwrap(); - create(&mut connection, &tagged(&space_id, &["urgent"]), T1).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - // normalize_tags folds case within one note; the collation extends that - // to the whole corpus, which is what the rail reads. - assert_eq!(view.available_tags.len(), 1); - } - - #[test] - fn criteria_combine_rather_than_replace_each_other() { - let mut connection = open_in_memory().unwrap(); - let here = space(&mut connection, "Perso"); - let elsewhere = space(&mut connection, "Boulot"); - - let target = create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - pinned: true, - tags: vec!["urgent".to_string()], - ..draft(&here) - }, - T0, - ) - .unwrap(); - // Each of these fails exactly one criterion. - create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - pinned: true, - tags: vec!["later".to_string()], - ..draft(&here) - }, - T0, - ) - .unwrap(); - create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - pinned: false, - tags: vec!["urgent".to_string()], - ..draft(&here) - }, - T0, - ) - .unwrap(); - create( - &mut connection, - &NoteDraft { - title: "autre".to_string(), - pinned: true, - tags: vec!["urgent".to_string()], - ..draft(&here) - }, - T0, - ) - .unwrap(); - create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - pinned: true, - tags: vec!["urgent".to_string()], - ..draft(&elsewhere) - }, - T0, - ) - .unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - space_id: Some(here), - search: "deploy".to_string(), - filter: NoteFilter::Pinned, - tags: vec!["urgent".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(matched_ids(&view), [target.id]); - } - - #[test] - fn a_note_matches_when_it_is_written_in_one_of_the_selected_languages() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let json = create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); - let yml = create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); - create(&mut connection, &written_in(&space_id, "py"), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - languages: vec!["json".to_string(), "yml".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - // A union like the tag rail, not an intersection: a note has exactly one - // language, so requiring all of them would always match nothing. - let ids = matched_ids(&view); - assert_eq!(ids.len(), 2); - assert!(ids.contains(&json.id) && ids.contains(&yml.id)); - assert!(view.is_filtering); - } - - #[test] - fn the_language_filter_combines_with_the_other_criteria() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let target = create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - ..written_in(&space_id, "yml") - }, - T0, - ) - .unwrap(); - // Same language, wrong search; and same search, wrong language. - create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); - create( - &mut connection, - &NoteDraft { - title: "deploy".to_string(), - ..written_in(&space_id, "json") - }, - T0, - ) - .unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - search: "deploy".to_string(), - languages: vec!["yml".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(matched_ids(&view), [target.id]); - } - - #[test] - fn an_unknown_selected_language_matches_nothing_rather_than_everything() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - languages: vec!["cobol".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - assert_eq!(view.matched, 0); - } - - #[test] - fn available_languages_are_sorted_and_de_duplicated() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); - create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); - create(&mut connection, &written_in(&space_id, "json"), T1).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - assert_eq!(view.available_languages, ["json", "yml"]); - } - - #[test] - fn available_languages_are_scoped_to_the_active_space() { - let mut connection = open_in_memory().unwrap(); - let here = space(&mut connection, "Perso"); - let elsewhere = space(&mut connection, "Boulot"); - create(&mut connection, &written_in(&here, "json"), T0).unwrap(); - create(&mut connection, &written_in(&elsewhere, "sql"), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - space_id: Some(here), - ..all_notes() - }, - ) - .unwrap(); - - // Offering a language that filters nothing in the current space is noise. - assert_eq!(view.available_languages, ["json"]); - } - - #[test] - fn available_languages_ignore_the_current_selection() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &written_in(&space_id, "json"), T0).unwrap(); - create(&mut connection, &written_in(&space_id, "yml"), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - languages: vec!["json".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - // Narrowing the rail to the current results would leave a single facet - // on screen and make a second selection impossible. - assert_eq!(view.available_languages, ["json", "yml"]); - assert_eq!(view.matched, 1); - } - - #[test] - fn available_tags_are_sorted_and_de_duplicated() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &tagged(&space_id, &["zeta", "alpha"]), T0).unwrap(); - create(&mut connection, &tagged(&space_id, &["alpha", "beta"]), T0).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - assert_eq!(view.available_tags, ["alpha", "beta", "zeta"]); - } - - #[test] - fn available_tags_are_scoped_to_the_active_space() { - let mut connection = open_in_memory().unwrap(); - let here = space(&mut connection, "Perso"); - let elsewhere = space(&mut connection, "Boulot"); - create(&mut connection, &tagged(&here, &["here-tag"]), T0).unwrap(); - create(&mut connection, &tagged(&elsewhere, &["elsewhere-tag"]), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - space_id: Some(here), - ..all_notes() - }, - ) - .unwrap(); - - // Offering a tag that filters nothing in the current space is noise. - assert_eq!(view.available_tags, ["here-tag"]); - } - - #[test] - fn available_tags_ignore_the_current_search_and_selection() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &tagged(&space_id, &["urgent"]), T0).unwrap(); - create(&mut connection, &tagged(&space_id, &["later"]), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - tags: vec!["urgent".to_string()], - ..all_notes() - }, - ) - .unwrap(); - - // Narrowing the rail to the current results would empty it after the - // first click and make a second selection impossible. - assert_eq!(view.available_tags, ["later", "urgent"]); - assert_eq!(view.matched, 1); - } - - #[test] - fn a_search_matching_nothing_reports_filtering_with_zero_matches() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - let view = query( - &mut connection, - &NotesQuery { - search: "introuvable".to_string(), - ..all_notes() - }, - ) - .unwrap(); - - // The pair (is_filtering, matched) is what lets the UI say "no results" - // rather than "this space is empty". - assert!(view.is_filtering); - assert_eq!(view.matched, 0); - } - - #[test] - fn the_view_orders_notes_most_recently_updated_first() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - let older = create(&mut connection, &draft(&space_id), T0).unwrap(); - let newer = create(&mut connection, &draft(&space_id), T1).unwrap(); - - let view = query(&mut connection, &all_notes()).unwrap(); - - assert_eq!(matched_ids(&view), [newer.id, older.id]); - } - - #[test] - fn tags_are_normalised_on_write() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - - let created = create( - &mut connection, - &tagged(&space_id, &[" #urgent ", "URGENT", "", " # ", "later"]), - T0, - ) - .unwrap(); - - // Padding and leading hashes are stripped, blanks dropped, and the - // case-insensitive duplicate collapses onto the first spelling. - assert_eq!(created.tags, ["later", "urgent"]); - assert_eq!(list(&mut connection).unwrap()[0].tags, ["later", "urgent"]); - } - - #[test] - fn a_normalised_write_returns_what_a_read_would_return() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - - let created = create(&mut connection, &tagged(&space_id, &["zeta", "alpha"]), T0).unwrap(); - - // The front adopts the returned note; a different order here would make - // the tags jump around on the next reload. - assert_eq!(created.tags, list(&mut connection).unwrap()[0].tags); - } - - #[test] - fn deleting_a_space_takes_its_notes_with_it() { - let mut connection = open_in_memory().unwrap(); - let space_id = space(&mut connection, "Perso"); - create(&mut connection, &draft(&space_id), T0).unwrap(); - - diesel::delete(spaces_table::table.find(&space_id)) - .execute(&mut connection) - .unwrap(); - - // No command exposes this yet, but the cascade must already hold: - // a note whose space is gone would be invisible and unreachable. - assert!(list(&mut connection).unwrap().is_empty()); - } -} diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index 124a174..4580de7 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -1,16 +1,12 @@ -//! Table Diesel de chaque table SQLite : le miroir typé du schéma que -//! `migrations/` construit. +//! Miroir typé du schéma que `migrations/` construit. //! -//! Écrit à la main plutôt que généré par `diesel print-schema`, qui exigerait -//! une base à jour sur la machine de build et rendrait `cargo check` dépendant -//! d'un fichier hors du dépôt. La contrepartie est de le tenir en phase avec les -//! migrations ; `check_for_backend` sur les structures de ligne et les tests de -//! `storage::` échouent bruyamment si les deux divergent. +//! Écrit à la main plutôt que par `diesel print-schema`, qui rendrait +//! `cargo check` dépendant d'une base à jour hors du dépôt. La contrepartie est +//! de le tenir en phase ; `check_for_backend` sur `NoteRow` fait échouer la +//! compilation si les deux divergent. //! -//! Ce qui **n'apparaît pas** ici et vit uniquement dans le SQL des migrations : -//! les `CHECK`, les `ON DELETE CASCADE` et la collation `NOCASE` de -//! `note_tags.tag`. Diesel ne les modélise pas — il les subit, ce qui est le bon -//! sens de la dépendance. +//! Les `CHECK`, les `ON DELETE CASCADE` et la collation `NOCASE` n'apparaissent +//! **pas** ici : Diesel ne les modélise pas, il les subit. diesel::table! { spaces (id) { diff --git a/src-tauri/src/storage/spaces.rs b/src-tauri/src/storage/spaces.rs index b058cc0..29288f5 100644 --- a/src-tauri/src/storage/spaces.rs +++ b/src-tauri/src/storage/spaces.rs @@ -1,12 +1,7 @@ //! Lecture et écriture des espaces. //! -//! Fonctions ordinaires prenant une `&mut SqliteConnection` : les -//! `#[tauri::command]` de `commands/spaces.rs` ne font que les appeler. Voir -//! `storage/mod.rs`. -//! -//! Pas de structure de ligne ici, contrairement aux notes : `Space` a deux -//! champs et traverse tel quel. Une `SpaceRow` identique au type du domaine -//! serait un mappeur d'identité, écrit pour la symétrie et pour rien d'autre. +//! Pas de structure de ligne ici, contrairement aux notes : `Space` a deux champs +//! et traverse tel quel. use diesel::dsl::sql; use diesel::prelude::*; @@ -17,8 +12,8 @@ use super::schema::{notes, spaces}; use crate::domain::space::Space; use uuid::Uuid; -/// Tous les espaces, triés par nom. Une liste vide est valide : c'est l'état du -/// premier lancement. Aucun espace « Tous » n'est fabriqué ici. +/// Une liste vide est valide : c'est l'état du premier lancement. Aucun espace +/// « Tous » n'est fabriqué ici. pub fn list(connection: &mut SqliteConnection) -> Result, StorageError> { let rows = spaces::table .select((spaces::id, spaces::name)) @@ -33,8 +28,8 @@ pub fn list(connection: &mut SqliteConnection) -> Result, StorageErro .collect()) } -/// Vérifié avant de ranger une note : la clé étrangère l'attraperait aussi, mais -/// avec un message SQLite illisible là où le front affiche l'erreur. +/// La clé étrangère l'attraperait aussi, mais avec un message SQLite illisible +/// là où le front affiche l'erreur. pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result { let found = spaces::table .find(id) @@ -45,20 +40,19 @@ pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result, ) -> Result<(), StorageError> { - // `spaces.name` n'est pas déclarée `NOCASE` — seul l'index unique l'est — - // donc la collation doit être posée sur la comparaison, faute de quoi elle - // se ferait en BINARY et laisserait passer « PERSO » à côté de « Perso ». + // ⚠️ `spaces.name` n'est pas déclarée `NOCASE` — seul l'index unique l'est — + // donc la collation doit être posée sur la comparaison, sans quoi « PERSO » + // passerait à côté de « Perso ». let mut query = spaces::table .filter( sql::("name = ") @@ -78,9 +72,7 @@ fn ensure_unique_name( Ok(()) } -/// Renvoie la version persistée : le front sélectionne aussitôt l'espace à -/// partir de cette valeur. `name` est attendu **déjà validé** (détouré, non -/// vide) — cette couche ne tranche que l'unicité. +/// `name` est attendu **déjà validé** : cette couche ne tranche que l'unicité. pub fn create(connection: &mut SqliteConnection, name: &str) -> Result { ensure_unique_name(connection, name, None)?; @@ -96,7 +88,7 @@ pub fn create(connection: &mut SqliteConnection, name: &str) -> Result Vec { - list(connection) - .unwrap() - .into_iter() - .map(|space| space.name) - .collect() - } - - #[test] - fn a_created_space_is_listed_back() { - let mut connection = open_in_memory().unwrap(); - - let created = create(&mut connection, "Perso").unwrap(); - let listed = list(&mut connection).unwrap(); - - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, created.id); - assert_eq!(listed[0].name, "Perso"); - } - - #[test] - fn each_space_gets_its_own_identifier() { - let mut connection = open_in_memory().unwrap(); - - let first = create(&mut connection, "Perso").unwrap(); - let second = create(&mut connection, "Boulot").unwrap(); - - assert_ne!(first.id, second.id); - } - - #[test] - fn spaces_are_listed_in_name_order() { - let mut connection = open_in_memory().unwrap(); - - create(&mut connection, "Veille").unwrap(); - create(&mut connection, "Boulot").unwrap(); - create(&mut connection, "perso").unwrap(); - - // Case-insensitive: a BINARY sort would file "perso" after "Veille". - assert_eq!(names(&mut connection), ["Boulot", "perso", "Veille"]); - } - - #[test] - fn a_duplicate_name_is_refused_regardless_of_case() { - let mut connection = open_in_memory().unwrap(); - create(&mut connection, "Perso").unwrap(); - - let error = create(&mut connection, "PERSO").unwrap_err(); - - assert!(matches!(error, StorageError::DuplicateSpaceName(_))); - assert_eq!(list(&mut connection).unwrap().len(), 1); - } - - #[test] - fn exists_distinguishes_known_from_unknown_identifiers() { - let mut connection = open_in_memory().unwrap(); - let space = create(&mut connection, "Perso").unwrap(); - - assert!(exists(&mut connection, &space.id).unwrap()); - assert!(!exists(&mut connection, "inconnu").unwrap()); - } - - #[test] - fn a_renamed_space_keeps_its_identifier() { - let mut connection = open_in_memory().unwrap(); - let space = create(&mut connection, "Perso").unwrap(); - - let renamed = rename(&mut connection, &space.id, "Personnel").unwrap(); - - // The id is what the notes point at: changing it would orphan them. - assert_eq!(renamed.id, space.id); - assert_eq!(renamed.name, "Personnel"); - assert_eq!(list(&mut connection).unwrap()[0].name, "Personnel"); - } - - #[test] - fn a_space_can_be_renamed_to_a_different_case_of_its_own_name() { - let mut connection = open_in_memory().unwrap(); - let space = create(&mut connection, "perso").unwrap(); - - // The uniqueness check is COLLATE NOCASE: without excluding the row - // being renamed, it would see the space as a duplicate of itself. - let renamed = rename(&mut connection, &space.id, "Perso").unwrap(); - - assert_eq!(renamed.name, "Perso"); - } - - #[test] - fn renaming_onto_another_space_name_is_refused() { - let mut connection = open_in_memory().unwrap(); - create(&mut connection, "Boulot").unwrap(); - let space = create(&mut connection, "Perso").unwrap(); - - let error = rename(&mut connection, &space.id, "BOULOT").unwrap_err(); - - assert!(matches!(error, StorageError::DuplicateSpaceName(_))); - assert_eq!(list(&mut connection).unwrap()[1].name, "Perso"); - } - - #[test] - fn renaming_an_unknown_space_reports_an_error() { - let mut connection = open_in_memory().unwrap(); - - let error = rename(&mut connection, "inconnu", "Perso").unwrap_err(); - - assert!(matches!(error, StorageError::SpaceNotFound(_))); - } - - #[test] - fn deleting_a_space_moves_its_notes_to_the_target() { - let mut connection = open_in_memory().unwrap(); - let doomed = create(&mut connection, "Perso").unwrap(); - let refuge = create(&mut connection, "Boulot").unwrap(); - note_in(&mut connection, &doomed.id); - - delete(&mut connection, &doomed.id, &refuge.id).unwrap(); - - // The schema cascades on space deletion; the move must happen first or - // the note disappears with its space. - let space_id = notes::table - .find("n-1") - .select(notes::space_id) - .first::(&mut connection) - .unwrap(); - assert_eq!(space_id, refuge.id); - assert_eq!(list(&mut connection).unwrap().len(), 1); - } - - #[test] - fn moving_notes_out_of_a_deleted_space_does_not_touch_their_timestamps() { - let mut connection = open_in_memory().unwrap(); - let doomed = create(&mut connection, "Perso").unwrap(); - let refuge = create(&mut connection, "Boulot").unwrap(); - note_in(&mut connection, &doomed.id); - - delete(&mut connection, &doomed.id, &refuge.id).unwrap(); - - // The canvas orders on updated_at: refreshing it would float the whole - // absorbed space to the top as if every note had just been edited. - let updated_at = notes::table - .find("n-1") - .select(notes::updated_at) - .first::(&mut connection) - .unwrap(); - assert_eq!(updated_at, T0); - } - - #[test] - fn deleting_an_empty_space_leaves_the_others_alone() { - let mut connection = open_in_memory().unwrap(); - let doomed = create(&mut connection, "Perso").unwrap(); - let refuge = create(&mut connection, "Boulot").unwrap(); - - delete(&mut connection, &doomed.id, &refuge.id).unwrap(); - - assert_eq!(names(&mut connection), ["Boulot"]); - } - - #[test] - fn deleting_an_unknown_space_reports_an_error() { - let mut connection = open_in_memory().unwrap(); - let refuge = create(&mut connection, "Boulot").unwrap(); - - let error = delete(&mut connection, "inconnu", &refuge.id).unwrap_err(); - - assert!(matches!(error, StorageError::SpaceNotFound(_))); - } - - #[test] - fn deleting_into_an_unknown_space_changes_nothing() { - let mut connection = open_in_memory().unwrap(); - let doomed = create(&mut connection, "Perso").unwrap(); - note_in(&mut connection, &doomed.id); - - let error = delete(&mut connection, &doomed.id, "inconnu").unwrap_err(); - - // Rolling back matters here: a half-applied delete would have taken the - // notes with it. - assert!(matches!(error, StorageError::SpaceNotFound(_))); - assert_eq!(list(&mut connection).unwrap().len(), 1); - assert_eq!( - notes::table - .count() - .get_result::(&mut connection) - .unwrap(), - 1 - ); - } -} diff --git a/src-tauri/tests/ipc_contract.rs b/src-tauri/tests/ipc_contract.rs new file mode 100644 index 0000000..f359f44 --- /dev/null +++ b/src-tauri/tests/ipc_contract.rs @@ -0,0 +1,381 @@ +//! Forme JSON de tout ce qui traverse le pont Tauri. +//! +//! Rassemblés ici plutôt que dispersés près de chaque type : c'est **un** seul +//! contrat, et le compilateur n'en vérifie rien. + +use chrono::{DateTime, Utc}; + +use devbox_lib::commands::error::{AppError, ErrorCode}; +use devbox_lib::domain::error::ValidationError; +use devbox_lib::domain::iso8601; +use devbox_lib::domain::language::Language; +use devbox_lib::domain::note::{DisplayNote, Note, NoteDraft, NoteLifecycle, NotePatch, decorate}; +use devbox_lib::domain::space::{Space, SpaceDraft}; +use devbox_lib::domain::view::{NoteFilter, NoteSection, NoteSectionKey, NotesQuery, NotesView}; +use devbox_lib::storage::StorageError; + +const NOW: &str = "2026-07-25T09:00:00.000Z"; + +fn at(iso: &str) -> DateTime { + iso8601::parse(iso).expect("les tests écrivent des instants valides") +} + +fn sample() -> Note { + Note { + id: "n-1".to_string(), + space_id: "s-1".to_string(), + title: "Titre".to_string(), + language: Language::Txt, + content: "Contenu".to_string(), + source: String::new(), + tags: vec!["auth".to_string()], + pinned: false, + created_at: at(NOW), + updated_at: at(NOW), + lifecycle: NoteLifecycle::Permanent, + } +} + +fn displayed(note: Note) -> DisplayNote { + decorate(note, at(NOW)) +} + +// --- Note ------------------------------------------------------------------ + +#[test] +fn a_note_serialises_with_camel_case_keys() { + let json = serde_json::to_value(sample()).unwrap(); + + // serde's default would emit the snake_case names and the front would read + // `undefined` where it expects a value. + assert!(json.get("spaceId").is_some()); + assert!(json.get("createdAt").is_some()); + assert!(json.get("updatedAt").is_some()); + assert!(json.get("space_id").is_none()); + assert!(json.get("created_at").is_none()); +} + +#[test] +fn a_permanent_lifecycle_serialises_as_a_tagged_object() { + let json = serde_json::to_value(sample()).unwrap(); + + // Not serde's default `"Permanent"` — the front discriminates on `kind`. + assert_eq!( + json["lifecycle"], + serde_json::json!({ "kind": "permanent" }) + ); +} + +#[test] +fn an_expiring_lifecycle_serialises_flat_with_its_date() { + let note = Note { + lifecycle: NoteLifecycle::Expires { + at: at("2026-08-01T00:00:00.000Z"), + }, + ..sample() + }; + + let json = serde_json::to_value(note).unwrap(); + + // Not `{"Expires":{"at":…}}`, which the TS discriminated union rejects. + // The date is compared as an instant, not as a byte-exact string: the front + // reads it with `new Date(iso)`, so the exact ISO spelling is not a contract. + assert_eq!(json["lifecycle"]["kind"], "expires"); + assert_eq!( + iso8601::parse(json["lifecycle"]["at"].as_str().unwrap()).unwrap(), + at("2026-08-01T00:00:00.000Z") + ); +} + +#[test] +fn a_patch_omitting_a_field_deserialises_to_none() { + // The front omits what it does not touch; absent must mean "leave alone". + let patch: NotePatch = serde_json::from_value(serde_json::json!({ + "title": "Nouveau titre" + })) + .unwrap(); + + assert_eq!(patch.title.as_deref(), Some("Nouveau titre")); + assert!(patch.content.is_none()); + assert!(patch.tags.is_none()); + assert!(patch.lifecycle.is_none()); +} + +#[test] +fn a_draft_is_read_from_the_camel_case_payload_the_front_sends() { + let draft: NoteDraft = serde_json::from_value(serde_json::json!({ + "spaceId": "s-1", + "title": "", + "language": "sql", + "content": "SELECT 1", + "source": "", + "tags": ["db"], + "pinned": true, + "lifecycle": { "kind": "expires", "at": "2026-08-01T00:00:00.000Z" } + })) + .unwrap(); + + assert_eq!(draft.space_id, "s-1"); + assert!(draft.pinned); + assert!(matches!(draft.lifecycle, NoteLifecycle::Expires { .. })); +} + +#[test] +fn a_decorated_note_serialises_flat_with_its_footer() { + let json = serde_json::to_value(displayed(sample())).unwrap(); + + // One object: the note's own fields sit alongside the display ones. + assert_eq!(json["id"], "n-1"); + assert_eq!(json["spaceId"], "s-1"); + assert_eq!(json["expiringSoon"], false); + assert_eq!(json["footer"]["kind"], "age"); + assert_eq!( + iso8601::parse(json["footer"]["at"].as_str().unwrap()).unwrap(), + at(NOW) + ); + assert!(json.get("note").is_none()); +} + +#[test] +fn a_source_footer_serialises_with_the_kind_the_front_discriminates_on() { + let note = Note { + pinned: true, + source: "API Gateway / Auth".to_string(), + ..sample() + }; + + let json = serde_json::to_value(displayed(note)).unwrap(); + + assert_eq!( + json["footer"], + serde_json::json!({ "kind": "source", "value": "API Gateway" }) + ); +} + +// --- Vue ------------------------------------------------------------------- + +#[test] +fn a_view_serialises_with_camel_case_keys() { + let view = NotesView { + sections: vec![NoteSection { + key: NoteSectionKey::Week, + notes: vec![displayed(sample())], + has_expiring_notes: false, + show_create_ghost: true, + }], + available_tags: vec!["auth".to_string()], + available_languages: vec![Language::Json], + is_filtering: false, + matched: 1, + }; + + let json = serde_json::to_value(view).unwrap(); + + assert!(json.get("availableTags").is_some()); + assert!(json.get("availableLanguages").is_some()); + assert!(json.get("isFiltering").is_some()); + assert!(json.get("available_tags").is_none()); + assert!(json.get("available_languages").is_none()); + assert!(json["sections"][0].get("hasExpiringNotes").is_some()); + assert!(json["sections"][0].get("showCreateGhost").is_some()); +} + +#[test] +fn a_section_key_serialises_as_the_translation_key_the_front_expects() { + let section = NoteSection { + key: NoteSectionKey::Older, + notes: Vec::new(), + has_expiring_notes: false, + show_create_ghost: false, + }; + + let json = serde_json::to_value(section).unwrap(); + + // The front builds `sections.older` from this; serde's default would + // emit "Older" and the lookup would miss. + assert_eq!(json["key"], "older"); +} + +#[test] +fn a_query_is_read_from_the_camel_case_payload_the_front_sends() { + let query: NotesQuery = serde_json::from_value(serde_json::json!({ + "spaceId": "s-1", + "search": "deploy", + "filter": "untriaged", + "tags": ["urgent"], + "languages": ["json", "yml"], + "now": NOW, + "tzOffsetMinutes": -120 + })) + .unwrap(); + + assert_eq!(query.space_id.as_deref(), Some("s-1")); + assert_eq!(query.filter, NoteFilter::Untriaged); + assert_eq!(query.languages, [Language::Json, Language::Yml]); + assert_eq!(query.tz_offset_minutes, -120); +} + +#[test] +fn a_null_space_is_read_as_every_space() { + // The front sends null, not an omitted key, when the user picks + // "all spaces" — that is a choice, not a missing value. + let query: NotesQuery = serde_json::from_value(serde_json::json!({ + "spaceId": null, + "search": "", + "filter": "all", + "tags": [], + "languages": [], + "now": NOW, + "tzOffsetMinutes": 0 + })) + .unwrap(); + + assert!(query.space_id.is_none()); +} + +// --- Espace ---------------------------------------------------------------- + +/// `rename_all` est sans effet tant que les champs tiennent en un mot : ce test +/// échouera le jour où un `created_at` s'ajoutera sans l'attribut, au lieu de +/// laisser le front lire `undefined`. +#[test] +fn a_space_serialises_with_the_keys_the_front_reads() { + let json = serde_json::to_value(Space { + id: "s-1".to_string(), + name: "Perso".to_string(), + }) + .unwrap(); + + assert_eq!(json, serde_json::json!({ "id": "s-1", "name": "Perso" })); +} + +#[test] +fn a_space_draft_is_read_from_the_payload_the_front_sends() { + let draft: SpaceDraft = + serde_json::from_value(serde_json::json!({ "name": "Boulot" })).unwrap(); + + assert_eq!(draft.name, "Boulot"); +} + +// --- Erreur ---------------------------------------------------------------- + +#[test] +fn a_code_serialises_in_camel_case() { + let json = serde_json::to_value(AppError::from(StorageError::NoteNotFound( + "n-1".to_string(), + ))) + .unwrap(); + + // The front discriminates on this exact spelling; serde's default would + // emit "NoteNotFound" and every branch would silently fall through. + assert_eq!(json["code"], "noteNotFound"); +} + +#[test] +fn a_duplicate_space_name_carries_the_name_as_a_parameter() { + let json = serde_json::to_value(AppError::from(StorageError::DuplicateSpaceName( + "Perso".to_string(), + ))) + .unwrap(); + + // Reading the name back out of `detail` would mean parsing a French sentence. + assert_eq!(json["code"], "duplicateSpaceName"); + assert_eq!(json["params"]["name"], "Perso"); +} + +#[test] +fn every_error_carries_a_non_empty_detail() { + let errors = [ + StorageError::NoteNotFound("n-1".to_string()), + StorageError::SpaceNotFound("s-1".to_string()), + StorageError::DuplicateSpaceName("Perso".to_string()), + StorageError::SchemaTooRecent("2099-01-01-000000".to_string()), + StorageError::Migration("base verrouillée".to_string()), + ]; + + for error in errors { + assert!(!AppError::from(error).detail.is_empty()); + } +} + +#[test] +fn a_refused_value_names_the_field_at_fault() { + let json = serde_json::to_value(AppError::from(ValidationError::new( + "language", + "« rust » n'est pas un langage reconnu", + ))) + .unwrap(); + + // Without it the banner would say "a value was rejected" and leave the user + // guessing which one. + assert_eq!(json["code"], "invalidInput"); + assert_eq!(json["params"]["field"], "language"); +} + +#[test] +fn a_schema_too_recent_degrades_to_storage_rather_than_leaking_a_dead_code() { + // It cannot cross the bridge (it aborts startup), so the front has no + // branch for it — `storage` is the honest code, and the detail carries + // the offending migration in plain text. + let error = AppError::from(StorageError::SchemaTooRecent( + "2099-01-01-000000".to_string(), + )); + + assert!(matches!(error.code, ErrorCode::Storage)); + assert!(error.detail.contains("2099-01-01-000000")); +} + +#[test] +fn params_are_absent_rather_than_null_when_there_is_nothing_to_interpolate() { + let json = serde_json::to_value(AppError::storage_unavailable()).unwrap(); + + assert_eq!(json["code"], "storageUnavailable"); + assert_eq!(json["params"], serde_json::json!({})); +} + +#[test] +fn an_unreadable_reference_instant_is_refused_at_the_bridge() { + // Elle l'était par `view::build`, quand `now` était une chaîne. Le type la + // porte désormais : le refus arrive à la désérialisation, avant qu'aucune + // section ne soit découpée sur un instant inventé. + let refused = serde_json::from_value::(serde_json::json!({ + "spaceId": null, + "search": "", + "filter": "all", + "tags": [], + "languages": [], + "now": "hier", + "tzOffsetMinutes": 0 + })); + + assert!(refused.is_err()); +} + +#[test] +fn an_unknown_language_is_refused_at_the_bridge() { + // Le front ne peut plus l'envoyer — les bindings en font une union — mais un + // appel direct au pont, si. La liste fermée le refuse ici. + let refused = serde_json::from_value::(serde_json::json!({ + "spaceId": "s-1", + "title": "", + "language": "rust", + "content": "", + "source": "", + "tags": [], + "pinned": false, + "lifecycle": { "kind": "permanent" } + })); + + assert!(refused.is_err()); +} + +#[test] +fn an_instant_crosses_as_a_string_the_front_can_read_as_a_date() { + // JSON n'a pas de type date : le front fait `new Date(iso)` à la frontière. + // Le format exact ne l'engage pas — c'est la **colonne** qui exige ses + // millisecondes, parce que le canevas trie dessus (voir `domain::iso8601`). + let json = serde_json::to_value(sample()).unwrap(); + + let updated_at = json["updatedAt"].as_str().unwrap(); + assert!(iso8601::parse(updated_at).is_ok()); +} diff --git a/src-tauri/tests/notes.rs b/src-tauri/tests/notes.rs new file mode 100644 index 0000000..3431e7b --- /dev/null +++ b/src-tauri/tests/notes.rs @@ -0,0 +1,1079 @@ +//! Notes lues et écrites contre une vraie base : le chemin complet +//! `storage::notes` puis `domain::view`, tel que `query_notes` l'assemble. +//! +//! Les règles ont leurs tests unitaires dans `domain/` ; ici on vérifie +//! qu'elles s'appliquent à ce que la base a réellement rendu. + +use diesel::SqliteConnection; +use diesel::prelude::*; + +use chrono::{DateTime, Utc}; + +use devbox_lib::domain::iso8601; +use devbox_lib::domain::language::Language; +use devbox_lib::domain::note::{Note, NoteDraft, NoteLifecycle, NotePatch}; +use devbox_lib::domain::view::{self, NoteFilter, NotesQuery, NotesView}; +use devbox_lib::storage::notes::{create, delete, fetch, update}; +use devbox_lib::storage::schema::{note_tags, spaces as spaces_table}; +use devbox_lib::storage::{StorageError, open_in_memory, spaces}; + +/// Un tel raccourci n'existe pas dans le code de production : il inviterait à +/// refiltrer côté front. +fn list(connection: &mut SqliteConnection) -> Result, StorageError> { + fetch( + connection, + &NotesQuery { + space_id: None, + search: String::new(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: t0(), + tz_offset_minutes: 0, + }, + ) + .map(|(notes, _)| notes) +} + +fn at(iso: &str) -> DateTime { + iso8601::parse(iso).expect("les tests écrivent des instants valides") +} + +fn t0() -> DateTime { + at("2026-07-25T09:00:00.000Z") +} + +fn t1() -> DateTime { + at("2026-07-25T10:00:00.000Z") +} + +fn query( + connection: &mut SqliteConnection, + request: &NotesQuery, +) -> Result { + let (notes, facets) = fetch(connection, request)?; + Ok(view::build(notes, facets, request)) +} + +fn space(connection: &mut SqliteConnection, name: &str) -> String { + spaces::create(connection, name).unwrap().id +} + +fn draft(space_id: &str) -> NoteDraft { + NoteDraft { + space_id: space_id.to_string(), + title: "Titre".to_string(), + language: Language::Txt, + content: "Contenu".to_string(), + source: "API Gateway / Auth".to_string(), + tags: vec!["auth".to_string(), "api".to_string()], + pinned: false, + lifecycle: NoteLifecycle::Permanent, + } +} + +#[test] +fn a_created_note_is_read_back_whole() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + let listed = list(&mut connection).unwrap(); + + assert_eq!(listed.len(), 1); + let note = &listed[0]; + assert_eq!(note.id, created.id); + assert_eq!(note.space_id, space_id); + assert_eq!(note.title, "Titre"); + assert_eq!(note.content, "Contenu"); + assert_eq!(note.source, "API Gateway / Auth"); + assert!(!note.pinned); + assert_eq!(note.tags, ["api", "auth"]); +} + +#[test] +fn creation_stamps_both_dates_identically() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + assert_eq!(created.created_at, t0()); + assert_eq!(created.updated_at, t0()); +} + +#[test] +fn an_expiring_lifecycle_survives_a_round_trip() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let expiring = NoteDraft { + lifecycle: NoteLifecycle::Expires { + at: at("2026-08-01T00:00:00.000Z"), + }, + ..draft(&space_id) + }; + + create(&mut connection, expiring, t0()).unwrap(); + + let deadline = at("2026-08-01T00:00:00.000Z"); + let listed = list(&mut connection).unwrap(); + assert!(matches!( + &listed[0].lifecycle, + NoteLifecycle::Expires { at } if *at == deadline + )); +} + +#[test] +fn dropping_an_expiry_clears_the_stored_date() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let expiring = NoteDraft { + lifecycle: NoteLifecycle::Expires { + at: at("2026-08-01T00:00:00.000Z"), + }, + ..draft(&space_id) + }; + let created = create(&mut connection, expiring, t0()).unwrap(); + + let patch = NotePatch { + lifecycle: Some(NoteLifecycle::Permanent), + ..NotePatch::default() + }; + update(&mut connection, &created.id, &patch, t1()).unwrap(); + + // The schema's CHECK ties the two columns together: leaving the date + // behind would make the write fail outright. + assert!(matches!( + list(&mut connection).unwrap()[0].lifecycle, + NoteLifecycle::Permanent + )); +} + +#[test] +fn creating_in_an_unknown_space_is_refused() { + let mut connection = open_in_memory().unwrap(); + + let error = create(&mut connection, draft("inconnu"), t0()).unwrap_err(); + + assert!(matches!(error, StorageError::SpaceNotFound(_))); + assert!(list(&mut connection).unwrap().is_empty()); +} + +#[test] +fn notes_are_listed_most_recently_updated_first() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + + let older = create(&mut connection, draft(&space_id), t0()).unwrap(); + let newer = create(&mut connection, draft(&space_id), t1()).unwrap(); + + let ids: Vec = list(&mut connection) + .unwrap() + .into_iter() + .map(|n| n.id) + .collect(); + + assert_eq!(ids, [newer.id, older.id]); +} + +#[test] +fn an_absent_patch_field_leaves_the_stored_value_untouched() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let patch = NotePatch { + title: Some("Nouveau titre".to_string()), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &patch, t1()).unwrap(); + + assert_eq!(updated.title, "Nouveau titre"); + assert_eq!(updated.content, "Contenu"); + assert_eq!(updated.source, "API Gateway / Auth"); + assert_eq!(updated.tags, ["api", "auth"]); + assert!(matches!(updated.lifecycle, NoteLifecycle::Permanent)); +} + +#[test] +fn updating_refreshes_updated_at_but_not_created_at() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let updated = update(&mut connection, &created.id, &NotePatch::default(), t1()).unwrap(); + + assert_eq!(updated.created_at, t0()); + assert_eq!(updated.updated_at, t1()); +} + +#[test] +fn pasting_into_a_freshly_created_note_settles_its_language() { + // End to end for the ordinary gesture: create empty, then paste. The + // rule is tested in `domain::detect`; here we check it actually reaches + // the stored row. + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let empty = NoteDraft { + language: Language::Txt, + content: String::new(), + ..draft(&space_id) + }; + let created = create(&mut connection, empty, t0()).unwrap(); + + let patch = NotePatch { + content: Some("interface Note { id: string }".to_string()), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &patch, t1()).unwrap(); + + assert_eq!(updated.language, Language::Ts); + assert_eq!(list(&mut connection).unwrap()[0].language, Language::Ts); +} + +#[test] +fn a_later_edit_does_not_move_the_language_again() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let empty = NoteDraft { + language: Language::Txt, + content: String::new(), + ..draft(&space_id) + }; + let created = create(&mut connection, empty, t0()).unwrap(); + + let first = NotePatch { + content: Some("SELECT 1".to_string()), + ..NotePatch::default() + }; + update(&mut connection, &created.id, &first, t1()).unwrap(); + + let second = NotePatch { + content: Some("interface Note { id: string }".to_string()), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &second, t1()).unwrap(); + + // The note had an identity by then; only the first content decides. + assert_eq!(updated.language, Language::Sql); +} + +#[test] +fn patching_tags_replaces_the_whole_set() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let patch = NotePatch { + tags: Some(vec!["sql".to_string()]), + ..NotePatch::default() + }; + update(&mut connection, &created.id, &patch, t1()).unwrap(); + + assert_eq!(list(&mut connection).unwrap()[0].tags, ["sql"]); +} + +#[test] +fn mixed_case_tags_come_back_in_the_same_order_a_reload_gives() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let patch = NotePatch { + tags: Some(vec!["Urgent".to_string(), "auth".to_string()]), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &patch, t1()).unwrap(); + + // The column is COLLATE NOCASE, so a read orders "auth" before "Urgent"; + // a byte-wise sort on the write path would answer the other way round and + // the editor's tags would shuffle on the next reload. + assert_eq!(updated.tags, ["auth", "Urgent"]); + assert_eq!(list(&mut connection).unwrap()[0].tags, updated.tags); +} + +#[test] +fn patching_tags_to_an_empty_list_clears_them() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let patch = NotePatch { + tags: Some(Vec::new()), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &patch, t1()).unwrap(); + + assert!(updated.tags.is_empty()); + assert!(list(&mut connection).unwrap()[0].tags.is_empty()); +} + +#[test] +fn a_note_can_be_moved_to_another_space() { + let mut connection = open_in_memory().unwrap(); + let origin = space(&mut connection, "Perso"); + let destination = space(&mut connection, "Boulot"); + let created = create(&mut connection, draft(&origin), t0()).unwrap(); + + let patch = NotePatch { + space_id: Some(destination.clone()), + ..NotePatch::default() + }; + let updated = update(&mut connection, &created.id, &patch, t1()).unwrap(); + + assert_eq!(updated.space_id, destination); +} + +#[test] +fn moving_a_note_to_an_unknown_space_is_refused_and_changes_nothing() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + let patch = NotePatch { + space_id: Some("inconnu".to_string()), + title: Some("Ne doit pas passer".to_string()), + ..NotePatch::default() + }; + let error = update(&mut connection, &created.id, &patch, t1()).unwrap_err(); + + assert!(matches!(error, StorageError::SpaceNotFound(_))); + let note = &list(&mut connection).unwrap()[0]; + assert_eq!(note.space_id, space_id); + assert_eq!(note.title, "Titre"); +} + +#[test] +fn updating_an_unknown_note_reports_an_error() { + let mut connection = open_in_memory().unwrap(); + + let error = update(&mut connection, "inconnu", &NotePatch::default(), t1()).unwrap_err(); + + assert!(matches!(error, StorageError::NoteNotFound(_))); +} + +#[test] +fn deleting_removes_the_note_and_its_tags() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + delete(&mut connection, &created.id).unwrap(); + + assert!(list(&mut connection).unwrap().is_empty()); + let orphan_tags = note_tags::table + .count() + .get_result::(&mut connection) + .unwrap(); + assert_eq!(orphan_tags, 0); +} + +#[test] +fn deleting_an_unknown_note_reports_an_error() { + let mut connection = open_in_memory().unwrap(); + + let error = delete(&mut connection, "inconnu").unwrap_err(); + + assert!(matches!(error, StorageError::NoteNotFound(_))); +} + +/// Neutral query: everything, no search, no tags. Tests override one field +/// at a time so each one states exactly what it exercises. +fn all_notes() -> NotesQuery { + NotesQuery { + space_id: None, + search: String::new(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: t1(), + tz_offset_minutes: 0, + } +} + +fn matched_ids(view: &NotesView) -> Vec { + view.sections + .iter() + .flat_map(|section| section.notes.iter().map(|note| note.id.clone())) + .collect() +} + +fn tagged(space_id: &str, tags: &[&str]) -> NoteDraft { + NoteDraft { + tags: tags.iter().copied().map(String::from).collect(), + ..draft(space_id) + } +} + +fn written_in(space_id: &str, language: Language) -> NoteDraft { + NoteDraft { + language, + ..draft(space_id) + } +} + +#[test] +fn a_query_without_criteria_returns_every_note() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + assert_eq!(view.matched, 1); + assert!(!view.is_filtering); +} + +#[test] +fn the_space_filter_excludes_the_other_spaces() { + let mut connection = open_in_memory().unwrap(); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); + let kept = create(&mut connection, draft(&here), t0()).unwrap(); + create(&mut connection, draft(&elsewhere), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + space_id: Some(here), + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(matched_ids(&view), [kept.id]); +} + +#[test] +fn no_space_means_every_space_rather_than_none() { + let mut connection = open_in_memory().unwrap(); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); + create(&mut connection, draft(&here), t0()).unwrap(); + create(&mut connection, draft(&elsewhere), t0()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + assert_eq!(view.matched, 2); +} + +#[test] +fn the_pinned_filter_keeps_only_pinned_notes() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let pinned = create( + &mut connection, + NoteDraft { + pinned: true, + ..draft(&space_id) + }, + t0(), + ) + .unwrap(); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + filter: NoteFilter::Pinned, + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(matched_ids(&view), [pinned.id]); +} + +#[test] +fn the_untriaged_filter_keeps_only_expiring_notes() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let expiring = create( + &mut connection, + NoteDraft { + lifecycle: NoteLifecycle::Expires { + at: at("2026-08-01T00:00:00.000Z"), + }, + ..draft(&space_id) + }, + t0(), + ) + .unwrap(); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + filter: NoteFilter::Untriaged, + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(matched_ids(&view), [expiring.id]); +} + +#[test] +fn a_quick_filter_alone_does_not_switch_to_results_mode() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + filter: NoteFilter::Pinned, + ..all_notes() + }, + ) + .unwrap(); + + // Pinned/untriaged narrow a view that stays chronological; only a search + // or a tag selection collapses it into a flat result list. + assert!(!view.is_filtering); +} + +#[test] +fn the_search_matches_the_title_the_content_and_the_tags() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let by_title = create( + &mut connection, + NoteDraft { + title: "Script de deploiement".to_string(), + content: String::new(), + tags: Vec::new(), + ..draft(&space_id) + }, + t0(), + ) + .unwrap(); + let by_content = create( + &mut connection, + NoteDraft { + title: String::new(), + content: "kubectl rollout".to_string(), + tags: Vec::new(), + ..draft(&space_id) + }, + t0(), + ) + .unwrap(); + let by_tag = create(&mut connection, tagged(&space_id, &["urgent"]), t0()).unwrap(); + + for (needle, expected) in [ + ("deploiement", &by_title), + ("rollout", &by_content), + ("urgent", &by_tag), + ] { + let view = query( + &mut connection, + &NotesQuery { + search: needle.to_string(), + ..all_notes() + }, + ) + .unwrap(); + assert_eq!( + matched_ids(&view), + [expected.id.as_str()], + "needle: {needle}" + ); + } +} + +#[test] +fn the_search_ignores_case_beyond_ascii() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create( + &mut connection, + NoteDraft { + title: "Étape de migration".to_string(), + ..draft(&space_id) + }, + t0(), + ) + .unwrap(); + + // SQLite's LOWER() only folds ASCII, so "É" would never match "é" if the + // search were pushed into SQL. This is why it is done in Rust. + let view = query( + &mut connection, + &NotesQuery { + search: "étape".to_string(), + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(view.matched, 1); +} + +#[test] +fn a_blank_search_is_not_a_search() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + search: " ".to_string(), + ..all_notes() + }, + ) + .unwrap(); + + assert!(!view.is_filtering); + assert_eq!(view.matched, 1); +} + +#[test] +fn a_note_matches_when_it_carries_at_least_one_selected_tag() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let one = create(&mut connection, tagged(&space_id, &["urgent"]), t0()).unwrap(); + let two = create(&mut connection, tagged(&space_id, &["later"]), t0()).unwrap(); + create(&mut connection, tagged(&space_id, &["neither"]), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + tags: vec!["urgent".to_string(), "later".to_string()], + ..all_notes() + }, + ) + .unwrap(); + + // A facet rail is a union, not an intersection: requiring every tag + // would make a second selection almost always empty. + let ids = matched_ids(&view); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&one.id) && ids.contains(&two.id)); + assert!(view.is_filtering); +} + +#[test] +fn a_selected_tag_is_normalised_like_a_stored_one() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, tagged(&space_id, &["urgent"]), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + tags: vec![" #urgent ".to_string()], + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(view.matched, 1); +} + +#[test] +fn a_selected_tag_matches_a_stored_one_of_a_different_case() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, tagged(&space_id, &["Urgent"]), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + tags: vec!["urgent".to_string()], + ..all_notes() + }, + ) + .unwrap(); + + // Without COLLATE NOCASE on note_tags.tag the IN (…) comparison runs in + // BINARY and misses: the rail would offer a facet selecting nothing. + assert_eq!(view.matched, 1); +} + +#[test] +fn the_rail_offers_one_facet_for_tags_differing_only_in_case() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, tagged(&space_id, &["Urgent"]), t0()).unwrap(); + create(&mut connection, tagged(&space_id, &["urgent"]), t1()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + // normalize_tags folds case within one note; the collation extends that + // to the whole corpus, which is what the rail reads. + assert_eq!(view.available_tags.len(), 1); +} + +#[test] +fn criteria_combine_rather_than_replace_each_other() { + let mut connection = open_in_memory().unwrap(); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); + + let target = create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + pinned: true, + tags: vec!["urgent".to_string()], + ..draft(&here) + }, + t0(), + ) + .unwrap(); + // Each of these fails exactly one criterion. + create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + pinned: true, + tags: vec!["later".to_string()], + ..draft(&here) + }, + t0(), + ) + .unwrap(); + create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + pinned: false, + tags: vec!["urgent".to_string()], + ..draft(&here) + }, + t0(), + ) + .unwrap(); + create( + &mut connection, + NoteDraft { + title: "autre".to_string(), + pinned: true, + tags: vec!["urgent".to_string()], + ..draft(&here) + }, + t0(), + ) + .unwrap(); + create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + pinned: true, + tags: vec!["urgent".to_string()], + ..draft(&elsewhere) + }, + t0(), + ) + .unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + space_id: Some(here), + search: "deploy".to_string(), + filter: NoteFilter::Pinned, + tags: vec!["urgent".to_string()], + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(matched_ids(&view), [target.id]); +} + +#[test] +fn a_note_matches_when_it_is_written_in_one_of_the_selected_languages() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let json = create(&mut connection, written_in(&space_id, Language::Json), t0()).unwrap(); + let yml = create(&mut connection, written_in(&space_id, Language::Yml), t0()).unwrap(); + create(&mut connection, written_in(&space_id, Language::Py), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + languages: vec![Language::Json, Language::Yml], + ..all_notes() + }, + ) + .unwrap(); + + // A union like the tag rail, not an intersection: a note has exactly one + // language, so requiring all of them would always match nothing. + let ids = matched_ids(&view); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&json.id) && ids.contains(&yml.id)); + assert!(view.is_filtering); +} + +#[test] +fn the_language_filter_combines_with_the_other_criteria() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let target = create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + ..written_in(&space_id, Language::Yml) + }, + t0(), + ) + .unwrap(); + create(&mut connection, written_in(&space_id, Language::Yml), t0()).unwrap(); + create( + &mut connection, + NoteDraft { + title: "deploy".to_string(), + ..written_in(&space_id, Language::Json) + }, + t0(), + ) + .unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + search: "deploy".to_string(), + languages: vec![Language::Yml], + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(matched_ids(&view), [target.id]); +} + +#[test] +fn available_languages_are_sorted_and_de_duplicated() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, written_in(&space_id, Language::Yml), t0()).unwrap(); + create(&mut connection, written_in(&space_id, Language::Json), t0()).unwrap(); + create(&mut connection, written_in(&space_id, Language::Json), t1()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + assert_eq!(view.available_languages, [Language::Json, Language::Yml]); +} + +#[test] +fn available_languages_are_scoped_to_the_active_space() { + let mut connection = open_in_memory().unwrap(); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); + create(&mut connection, written_in(&here, Language::Json), t0()).unwrap(); + create(&mut connection, written_in(&elsewhere, Language::Sql), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + space_id: Some(here), + ..all_notes() + }, + ) + .unwrap(); + + // Offering a language that filters nothing in the current space is noise. + assert_eq!(view.available_languages, [Language::Json]); +} + +#[test] +fn available_languages_ignore_the_current_selection() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, written_in(&space_id, Language::Json), t0()).unwrap(); + create(&mut connection, written_in(&space_id, Language::Yml), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + languages: vec![Language::Json], + ..all_notes() + }, + ) + .unwrap(); + + // Narrowing the rail to the current results would make a second selection + // impossible. + assert_eq!(view.available_languages, [Language::Json, Language::Yml]); + assert_eq!(view.matched, 1); +} + +#[test] +fn available_tags_are_sorted_and_de_duplicated() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, tagged(&space_id, &["zeta", "alpha"]), t0()).unwrap(); + create(&mut connection, tagged(&space_id, &["alpha", "beta"]), t0()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + assert_eq!(view.available_tags, ["alpha", "beta", "zeta"]); +} + +#[test] +fn available_tags_are_scoped_to_the_active_space() { + let mut connection = open_in_memory().unwrap(); + let here = space(&mut connection, "Perso"); + let elsewhere = space(&mut connection, "Boulot"); + create(&mut connection, tagged(&here, &["here-tag"]), t0()).unwrap(); + create( + &mut connection, + tagged(&elsewhere, &["elsewhere-tag"]), + t0(), + ) + .unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + space_id: Some(here), + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(view.available_tags, ["here-tag"]); +} + +#[test] +fn available_tags_ignore_the_current_search_and_selection() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, tagged(&space_id, &["urgent"]), t0()).unwrap(); + create(&mut connection, tagged(&space_id, &["later"]), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + tags: vec!["urgent".to_string()], + ..all_notes() + }, + ) + .unwrap(); + + assert_eq!(view.available_tags, ["later", "urgent"]); + assert_eq!(view.matched, 1); +} + +#[test] +fn a_search_matching_nothing_reports_filtering_with_zero_matches() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + let view = query( + &mut connection, + &NotesQuery { + search: "introuvable".to_string(), + ..all_notes() + }, + ) + .unwrap(); + + // The pair (is_filtering, matched) is what lets the UI say "no results" + // rather than "this space is empty". + assert!(view.is_filtering); + assert_eq!(view.matched, 0); +} + +#[test] +fn the_view_orders_notes_most_recently_updated_first() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let older = create(&mut connection, draft(&space_id), t0()).unwrap(); + let newer = create(&mut connection, draft(&space_id), t1()).unwrap(); + + let view = query(&mut connection, &all_notes()).unwrap(); + + assert_eq!(matched_ids(&view), [newer.id, older.id]); +} + +#[test] +fn tags_are_normalised_on_write() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + + let created = create( + &mut connection, + tagged(&space_id, &[" #urgent ", "URGENT", "", " # ", "later"]), + t0(), + ) + .unwrap(); + + assert_eq!(created.tags, ["later", "urgent"]); + assert_eq!(list(&mut connection).unwrap()[0].tags, ["later", "urgent"]); +} + +#[test] +fn a_normalised_write_returns_what_a_read_would_return() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + + let created = create(&mut connection, tagged(&space_id, &["zeta", "alpha"]), t0()).unwrap(); + + // The front adopts the returned note; a different order here would make + // the tags jump around on the next reload. + assert_eq!(created.tags, list(&mut connection).unwrap()[0].tags); +} + +#[test] +fn deleting_a_space_takes_its_notes_with_it() { + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + create(&mut connection, draft(&space_id), t0()).unwrap(); + + diesel::delete(spaces_table::table.find(&space_id)) + .execute(&mut connection) + .unwrap(); + + // No command exposes this yet, but the cascade must already hold: + // a note whose space is gone would be invisible and unreachable. + assert!(list(&mut connection).unwrap().is_empty()); +} + +#[test] +fn a_stored_date_that_is_out_of_format_is_reported_rather_than_guessed() { + // Les branches « date illisible » du domaine ont disparu avec le typage : + // la faillibilité a migré ici, où elle est signalée au lieu d'être devinée. + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let created = create(&mut connection, draft(&space_id), t0()).unwrap(); + + diesel::update(devbox_lib::storage::schema::notes::table.find(&created.id)) + .set(devbox_lib::storage::schema::notes::created_at.eq("pas une date")) + .execute(&mut connection) + .unwrap(); + + let error = list(&mut connection).unwrap_err(); + + assert!(matches!( + error, + StorageError::CorruptRow { + field: "createdAt", + .. + } + )); +} + +#[test] +fn a_stored_date_always_carries_its_milliseconds() { + // Le canevas trie sur cette colonne TEXT : sans millisecondes, deux notes de + // la même seconde s'ordonnent à l'envers (voir `domain::iso8601`). + let mut connection = open_in_memory().unwrap(); + let space_id = space(&mut connection, "Perso"); + let round_second = at("2026-07-25T09:00:00Z"); + + let created = create(&mut connection, draft(&space_id), round_second).unwrap(); + + let stored: String = devbox_lib::storage::schema::notes::table + .find(&created.id) + .select(devbox_lib::storage::schema::notes::updated_at) + .first(&mut connection) + .unwrap(); + + assert_eq!(stored, "2026-07-25T09:00:00.000Z"); +} diff --git a/src-tauri/tests/spaces.rs b/src-tauri/tests/spaces.rs new file mode 100644 index 0000000..680207e --- /dev/null +++ b/src-tauri/tests/spaces.rs @@ -0,0 +1,220 @@ +//! Espaces lus et écrits contre une vraie base — dont la suppression, qui +//! déplace les notes avant de supprimer, sous peine de cascade. + +use diesel::SqliteConnection; +use diesel::prelude::*; + +use devbox_lib::storage::schema::notes; +use devbox_lib::storage::spaces::{create, delete, exists, list, rename}; +use devbox_lib::storage::{StorageError, open_in_memory}; + +const T0: &str = "2026-07-25T09:00:00.000Z"; + +/// Note posée directement en base : ces tests portent sur les espaces, et +/// passer par `notes::create` y ferait entrer ses propres règles. +fn note_in(connection: &mut SqliteConnection, space_id: &str) { + diesel::insert_into(notes::table) + .values(( + notes::id.eq("n-1"), + notes::space_id.eq(space_id), + notes::title.eq("A"), + notes::language.eq("txt"), + notes::content.eq(""), + notes::source.eq(""), + notes::pinned.eq(false), + notes::created_at.eq(T0), + notes::updated_at.eq(T0), + notes::lifecycle_kind.eq("permanent"), + )) + .execute(connection) + .unwrap(); +} + +fn names(connection: &mut SqliteConnection) -> Vec { + list(connection) + .unwrap() + .into_iter() + .map(|space| space.name) + .collect() +} + +#[test] +fn a_created_space_is_listed_back() { + let mut connection = open_in_memory().unwrap(); + + let created = create(&mut connection, "Perso").unwrap(); + let listed = list(&mut connection).unwrap(); + + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, created.id); + assert_eq!(listed[0].name, "Perso"); +} + +#[test] +fn each_space_gets_its_own_identifier() { + let mut connection = open_in_memory().unwrap(); + + let first = create(&mut connection, "Perso").unwrap(); + let second = create(&mut connection, "Boulot").unwrap(); + + assert_ne!(first.id, second.id); +} + +#[test] +fn spaces_are_listed_in_name_order() { + let mut connection = open_in_memory().unwrap(); + + create(&mut connection, "Veille").unwrap(); + create(&mut connection, "Boulot").unwrap(); + create(&mut connection, "perso").unwrap(); + + // Case-insensitive: a BINARY sort would file "perso" after "Veille". + assert_eq!(names(&mut connection), ["Boulot", "perso", "Veille"]); +} + +#[test] +fn a_duplicate_name_is_refused_regardless_of_case() { + let mut connection = open_in_memory().unwrap(); + create(&mut connection, "Perso").unwrap(); + + let error = create(&mut connection, "PERSO").unwrap_err(); + + assert!(matches!(error, StorageError::DuplicateSpaceName(_))); + assert_eq!(list(&mut connection).unwrap().len(), 1); +} + +#[test] +fn exists_distinguishes_known_from_unknown_identifiers() { + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "Perso").unwrap(); + + assert!(exists(&mut connection, &space.id).unwrap()); + assert!(!exists(&mut connection, "inconnu").unwrap()); +} + +#[test] +fn a_renamed_space_keeps_its_identifier() { + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "Perso").unwrap(); + + let renamed = rename(&mut connection, &space.id, "Personnel").unwrap(); + + // The id is what the notes point at: changing it would orphan them. + assert_eq!(renamed.id, space.id); + assert_eq!(renamed.name, "Personnel"); + assert_eq!(list(&mut connection).unwrap()[0].name, "Personnel"); +} + +#[test] +fn a_space_can_be_renamed_to_a_different_case_of_its_own_name() { + let mut connection = open_in_memory().unwrap(); + let space = create(&mut connection, "perso").unwrap(); + + // The uniqueness check is COLLATE NOCASE: without excluding the row + // being renamed, it would see the space as a duplicate of itself. + let renamed = rename(&mut connection, &space.id, "Perso").unwrap(); + + assert_eq!(renamed.name, "Perso"); +} + +#[test] +fn renaming_onto_another_space_name_is_refused() { + let mut connection = open_in_memory().unwrap(); + create(&mut connection, "Boulot").unwrap(); + let space = create(&mut connection, "Perso").unwrap(); + + let error = rename(&mut connection, &space.id, "BOULOT").unwrap_err(); + + assert!(matches!(error, StorageError::DuplicateSpaceName(_))); + assert_eq!(list(&mut connection).unwrap()[1].name, "Perso"); +} + +#[test] +fn renaming_an_unknown_space_reports_an_error() { + let mut connection = open_in_memory().unwrap(); + + let error = rename(&mut connection, "inconnu", "Perso").unwrap_err(); + + assert!(matches!(error, StorageError::SpaceNotFound(_))); +} + +#[test] +fn deleting_a_space_moves_its_notes_to_the_target() { + let mut connection = open_in_memory().unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + note_in(&mut connection, &doomed.id); + + delete(&mut connection, &doomed.id, &refuge.id).unwrap(); + + // The schema cascades on space deletion; the move must happen first or + // the note disappears with its space. + let space_id = notes::table + .find("n-1") + .select(notes::space_id) + .first::(&mut connection) + .unwrap(); + assert_eq!(space_id, refuge.id); + assert_eq!(list(&mut connection).unwrap().len(), 1); +} + +#[test] +fn moving_notes_out_of_a_deleted_space_does_not_touch_their_timestamps() { + let mut connection = open_in_memory().unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + note_in(&mut connection, &doomed.id); + + delete(&mut connection, &doomed.id, &refuge.id).unwrap(); + + // The canvas orders on updated_at: refreshing it would float the whole + // absorbed space to the top as if every note had just been edited. + let updated_at = notes::table + .find("n-1") + .select(notes::updated_at) + .first::(&mut connection) + .unwrap(); + assert_eq!(updated_at, T0); +} + +#[test] +fn deleting_an_empty_space_leaves_the_others_alone() { + let mut connection = open_in_memory().unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + + delete(&mut connection, &doomed.id, &refuge.id).unwrap(); + + assert_eq!(names(&mut connection), ["Boulot"]); +} + +#[test] +fn deleting_an_unknown_space_reports_an_error() { + let mut connection = open_in_memory().unwrap(); + let refuge = create(&mut connection, "Boulot").unwrap(); + + let error = delete(&mut connection, "inconnu", &refuge.id).unwrap_err(); + + assert!(matches!(error, StorageError::SpaceNotFound(_))); +} + +#[test] +fn deleting_into_an_unknown_space_changes_nothing() { + let mut connection = open_in_memory().unwrap(); + let doomed = create(&mut connection, "Perso").unwrap(); + note_in(&mut connection, &doomed.id); + + let error = delete(&mut connection, &doomed.id, "inconnu").unwrap_err(); + + // Rolling back matters here: a half-applied delete would have taken the + // notes with it. + assert!(matches!(error, StorageError::SpaceNotFound(_))); + assert_eq!(list(&mut connection).unwrap().len(), 1); + assert_eq!( + notes::table + .count() + .get_result::(&mut connection) + .unwrap(), + 1 + ); +} diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index e6b66b9..6519481 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -13,22 +13,14 @@ export const commands = { updateNote: (id: string, patch: NotePatch) => typedError(__TAURI_INVOKE("update_note", { id, patch })), deleteNote: (id: string) => typedError(__TAURI_INVOKE("delete_note", { id })), listSpaces: () => typedError(__TAURI_INVOKE("list_spaces")), - /** Le front sélectionne aussitôt l'espace à partir de la valeur renvoyée. */ createSpace: (draft: SpaceDraft) => typedError(__TAURI_INVOKE("create_space", { draft })), - /** Même brouillon qu'à la création, donc même validation. */ renameSpace: (id: string, draft: SpaceDraft) => typedError(__TAURI_INVOKE("rename_space", { id, draft })), - /** - * Supprime un espace après avoir transféré ses notes vers `target_space_id`. - * - * Tauri v2 renomme les arguments en camelCase ; c'est `bindings.ts` qui porte - * désormais le `targetSpaceId` correspondant, sans qu'on ait à l'orthographier. - */ + /** Transfère les notes vers `target_space_id` avant de supprimer. */ deleteSpace: (id: string, targetSpaceId: string) => typedError(__TAURI_INVOKE("delete_space", { id, targetSpaceId })), /** - * Ne renvoie **pas** de `Result` : une barre système absente n'est pas une - * panne que le front puisse traiter, et lui inventer un code d'erreur - * ajouterait une branche que rien n'afficherait jamais. L'échec est journalisé - * côté natif, comme pour un raccourci global indisponible. + * Ne renvoie **pas** de `Result` : une barre système absente n'est pas une panne + * que le front puisse traiter, et lui inventer un code ajouterait une branche + * que rien n'afficherait. L'échec est journalisé côté natif. */ syncTray: (labels: TrayLabels) => __TAURI_INVOKE("sync_tray", { labels }), }; @@ -38,16 +30,13 @@ export type AppError = { code: ErrorCode, /** Valeurs à interpoler dans le message traduit, ex. `{ "name": "Perso" }`. */ params: { [key in string]: string }, - /** - * Message technique, affiché en second plan de la bannière. Pas traduit, - * mais lisible. - */ + /** Affiché en second plan de la bannière. Pas traduit, mais lisible. */ detail: string, }; /** - * Note augmentée de ce que l'affichage doit savoir. `flatten` aplatit la note - * dans l'objet JSON : le front n'a qu'un seul type de note. + * `flatten` aplatit la note dans le même objet JSON : le front n'a qu'un seul + * type de note. */ export type DisplayNote = { footer: NoteFooter, @@ -55,52 +44,52 @@ export type DisplayNote = { } & Note; /** - * Ajouter une variante la fait apparaître dans le `bindings.ts` généré, ce qui - * casse la compilation du front tant que `CODE_KEYS` + * Ajouter une variante casse la compilation du front tant que `CODE_KEYS` * (`core/errors/error-notifier.service.ts`) et les deux locales n'ont pas leur - * clé — le miroir n'est plus tenu à la main. + * clé. * * Pas de variante « schéma trop récent » : cette panne avorte le lancement * pendant la migration, aucune commande ne peut la renvoyer. */ export type ErrorCode = "noteNotFound" | "spaceNotFound" | "duplicateSpaceName" | -/** Donnée reçue non conforme. Le paramètre `field` nomme le champ en cause. */ +/** Le paramètre `field` nomme le champ en cause. */ "invalidInput" | /** Mutex empoisonné : une commande a paniqué en tenant la connexion. */ -"storageUnavailable" | -/** Panne de lecture ou d'écriture SQLite. */ -"storage"; +"storageUnavailable" | "storage"; + +/** + * Liste **fermée**, et c'est tout l'intérêt : le front la reçoit en union + * TypeScript générée, donc une valeur inconnue ne compile plus chez lui au lieu + * d'être refusée à l'exécution. + */ +export type Language = "json" | "js" | "ts" | "py" | "sql" | "yml" | "toml" | "xml" | "html" | "css" | "sh" | "md" | +/** + * Défaut, et **signal que le front n'a rien choisi** : c'est lui que la + * création remplace par une détection. + */ +"txt"; export type Note = { id: string, - /** - * Espace de rangement. C'est la requête qui filtre dessus ; le stockage - * refuse de créer une note dans un espace inconnu. - */ spaceId: string, - /** - * Peut être vide : une note fraîchement créée n'a pas encore de titre, - * l'interface affiche un libellé traduit à la place. - */ + /** Peut être vide : l'interface affiche alors un libellé traduit. */ title: string, - /** "json" | "js" | "py" | "sql" | "yml" | "txt". */ - language: string, + language: Language, content: string, - /** Chemin de contexte libre, ex. "API Gateway / Auth". Peut être vide. */ + /** Fil d'Ariane libre, ex. "API Gateway / Auth". Peut être vide. */ source: string, tags: string[], pinned: boolean, - /** ISO 8601, ex. "2026-07-25T09:12:00.000Z". */ createdAt: string, updatedAt: string, lifecycle: NoteLifecycle, }; -/** Création : ni identifiant ni horodatages — c'est la persistance qui les attribue. */ +/** Ni identifiant ni horodatages : la persistance les attribue. */ export type NoteDraft = { spaceId: string, title: string, - language: string, + language: Language, content: string, source: string, tags: string[], @@ -108,43 +97,31 @@ export type NoteDraft = { lifecycle: NoteLifecycle, }; -/** - * Filtre rapide de la barre d'outils. `Untriaged` = notes portant une date - * d'expiration, c'est-à-dire celles dont on n'a pas encore décidé du sort. - */ +/** `Untriaged` = notes portant une échéance, celles dont le sort n'est pas décidé. */ export type NoteFilter = "all" | "pinned" | "untriaged"; -/** Contenu du pied d'une carte — la **décision**, pas le rendu. */ -export type NoteFooter = /** - * Note épinglée portant un contexte : elle est là pour durer, savoir d'où - * elle vient est plus utile que son âge. + * Pied d'une carte : la **décision**, pas le rendu. Les variantes datées + * portent une date et non un libellé — « il y a 4 min » doit vieillir tout seul + * à l'écran, donc le formatage reste au front. */ -{ kind: "source"; value: string } | -/** Échéance d'une note éphémère. */ -{ kind: "expiry"; at: string } | -/** Âge de la dernière modification — le cas ordinaire. */ -{ kind: "age"; at: string }; - -export type NoteLifecycle = -/** Note permanente. */ -{ kind: "permanent" } | -/** Note éphémère : elle est « à trier » jusqu'à cette date. */ +export type NoteFooter = { kind: "source"; value: string } | { kind: "expiry"; at: string } | { kind: "age"; at: string }; + +export type NoteLifecycle = { kind: "permanent" } | +/** « À trier » jusqu'à cette date. */ { kind: "expires"; at: string }; /** - * Modification partielle : un champ à `None` reste **inchangé** en base. + * Un champ à `None` reste **inchangé** en base. * - * `#[specta(optional)]` génère `title?: string | null` plutôt que - * `title: string | null` : le front **omet** les clés qu'il ne touche pas, et - * un type qui les exigerait toutes l'obligerait à envoyer des `null`, c'est-à-dire - * à écraser ce qu'il voulait laisser intact. + * `#[specta(optional)]` rend les clés omissibles côté TypeScript. Sans lui le + * front devrait envoyer des `null` pour les champs qu'il ne touche pas — donc + * écraser ce qu'il voulait laisser intact. */ export type NotePatch = { - /** Renseigné uniquement lors d'un déplacement de note vers un autre espace. */ spaceId?: string | null, title?: string | null, - language?: string | null, + language?: Language | null, content?: string | null, source?: string | null, tags?: string[] | null, @@ -154,79 +131,64 @@ export type NotePatch = { export type NoteSection = { key: NoteSectionKey, - /** Au moins une note arrive à échéance, au sens du seuil unique de `note`. */ hasExpiringNotes: boolean, notes: DisplayNote[], - /** Affiche la carte fantôme « coller ou créer » en fin de section. */ showCreateGhost: boolean, }; /** - * Sert de **clé de traduction** côté front (`sections.`) : aucun libellé - * lisible ne traverse le pont. + * **Clé de traduction** côté front (`sections.`) : aucun libellé lisible + * ne traverse le pont. */ export type NoteSectionKey = "pinned" | "today" | "week" | "older" | "results"; -/** - * Tout y est explicite : la requête ne lit ni horloge ni fuseau, ce qui la - * rend reproductible en test. - */ +/** Ni horloge ni fuseau lus ici : tout est explicite, donc reproductible en test. */ export type NotesQuery = { /** * `None` = « tous les espaces » — un choix, pas une absence de choix : il * n'existe aucun espace « Tous » côté données. */ spaceId: string | null, - /** Cherché dans le titre, les tags et le contenu. Vide = pas de recherche. */ + /** Vide = pas de recherche. */ search: string, filter: NoteFilter, - /** Tags du rail. Une note passe si elle en porte **au moins un**. */ + /** Une note passe si elle porte **au moins un** de ces tags. */ tags: string[], - /** Langages du rail, même sémantique d'union. Vide = tous. */ - languages: string[], - /** Instant de référence ISO 8601 UTC, fourni par `ClockService`. */ + /** Même sémantique d'union. Vide = tous. */ + languages: Language[], now: string, /** * ⚠️ `Date#getTimezoneOffset()`, dont la valeur est l'**opposé** du décalage - * (UTC+2 donne −120). Nécessaire parce que les sections raisonnent en jours - * locaux : à 23 h à Paris, `now` en UTC est déjà demain. + * (UTC+2 donne −120). Les sections raisonnent en jours locaux : à 23 h à + * Paris, `now` en UTC est déjà demain. */ tzOffsetMinutes: number, }; -/** Ce que le canevas affiche, tel quel. */ export type NotesView = { sections: NoteSection[], /** - * Portés à l'**espace**, pas au filtre courant : n'afficher que les tags des - * notes déjà filtrées rendrait le rail inutilisable dès la 1re sélection. + * Portées à l'**espace**, pas au filtre courant : n'offrir que les facettes + * des notes déjà filtrées viderait le rail dès la 1re sélection. */ availableTags: string[], - /** Portés à l'espace, même raison. */ - availableLanguages: string[], - /** - * Une recherche ou une facette est active. Le front distingue ainsi - * « aucun résultat » d'« espace vide ». - */ + availableLanguages: Language[], + /** Distingue « aucun résultat » d'« espace vide ». */ isFiltering: boolean, /** - * Notes retenues, toutes sections confondues. `u32` et non `usize` : Specta - * refuse d'exporter les types de la taille d'un `BigInt`, que JSON ne sait - * pas rendre sans perte de précision. + * `u32` et non `usize` : Specta refuse d'exporter un type de la taille d'un + * `BigInt`, que JSON ne rend pas sans perte de précision. */ matched: number, }; export type Space = { id: string, - /** - * L'unicité, insensible à la casse, est tranchée par la persistance : un - * doublon ressort en `ErrorCode::DuplicateSpaceName`. - */ + /** Unicité insensible à la casse, tranchée par la persistance. */ name: string, }; -/** Pas d'identifiant : il est attribué par la persistance. */ +/** Pas d'identifiant : la persistance l'attribue. */ export type SpaceDraft = { name: string, }; diff --git a/src/app/core/language/language.model.ts b/src/app/core/language/language.model.ts index af029e5..d80295a 100644 --- a/src/app/core/language/language.model.ts +++ b/src/app/core/language/language.model.ts @@ -1,6 +1,14 @@ -/** Langages reconnus pour la coloration des badges et du corps des notes. */ -export type LanguageTag = - 'json' | 'js' | 'ts' | 'py' | 'sql' | 'yml' | 'toml' | 'xml' | 'html' | 'css' | 'sh' | 'md' | 'txt'; +import type { Language } from '@core/ipc/bindings'; + +/** + * Langages reconnus pour la coloration des badges et du corps des notes. Simple + * alias de l'union **générée** depuis l'enum `Language` de + * `src-tauri/src/domain/language.rs` : ce n'est plus un miroir tenu à la main, + * une variante ajoutée en Rust apparaît ici dès la régénération et casse la + * compilation partout où elle n'est pas traitée — à commencer par + * `LANGUAGE_LABELS`, qui doit rester exhaustif. + */ +export type LanguageTag = Language; /** * L'ordre des clés est celui du sélecteur de l'éditeur (cf. `LANGUAGE_OPTIONS`) : @@ -26,8 +34,9 @@ export const LANGUAGE_LABELS: Record = { export const FALLBACK_LANGUAGE: LanguageTag = 'txt'; /** - * Garde de type utilisée au franchissement de la frontière IPC : le backend Rust - * pourrait renvoyer un langage que cette version du front ignore encore. + * Restreint une chaîne **libre** — la valeur d'un `