diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e1e2db..ded604a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,24 @@ jobs: librsvg2-dev \ patchelf + # WHY (T12): tauri-build validates `bundle.externalBin` paths + # during EVERY cargo compile of `perima-desktop` (not just + # `tauri build` — issue tauri-apps/tauri#14602). With + # `externalBin = ["binaries/ffmpeg"]` declared in tauri.conf.json, + # `cargo {build,clippy,test} -p perima-desktop` fails immediately + # without `crates/desktop/binaries/ffmpeg-{target-triple}` on + # disk. The fetch script downloads a pinned static build on Linux + # (johnvansickle.com release-amd64-static) and writes a + # zero-byte stub on macOS+Windows so the compile passes; runtime + # transcription on those platforms surfaces a clear + # `BinaryNotFound` until the T12-followup macOS+Windows fetchers + # land. The script is idempotent (skips download if the binary is + # already present) so Swatinem/rust-cache hits don't redo work. + # MUST run BEFORE `just ci` — otherwise tauri-build aborts. + - name: Fetch ffmpeg sidecar + shell: bash + run: just sidecar + - name: Run just ci shell: bash run: just ci @@ -129,6 +147,13 @@ jobs: librsvg2-dev \ patchelf + # WHY (T12): same rationale as the matrix job's sidecar fetch — + # `just bindings` runs `cargo build -p perima-desktop --features + # specta-export`, which compiles tauri-build, which validates the + # externalBin path. Must run before `just bindings`. + - name: Fetch ffmpeg sidecar + run: just sidecar + # WHY: regenerate bindings.ts via the specta-export feature and # fail the job if `git diff` shows the committed copy is stale. # Catches every Rust-side change to a #[tauri::command] signature, diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 6e668bf..285d31c 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -65,6 +65,18 @@ jobs: librsvg2-dev \ patchelf + # WHY (T12): cargo-mutants' baseline-build pass compiles the + # WHOLE workspace including perima-desktop (exclude_globs only + # filters mutation generation, not baseline compile). tauri-build + # validates `bundle.externalBin` paths during compile (issue + # tauri-apps/tauri#14602), so the binary must exist on disk + # before any cargo invocation. + - uses: taiki-e/install-action@v2 + with: + tool: just + - name: Fetch ffmpeg sidecar + run: just sidecar + # WHY `| tee git.diff` (vs `> git.diff`): tee echoes the diff into # the CI log so reviewers can see what cargo-mutants will operate # on without downloading the artifact. Matches the canonical diff --git a/.gitignore b/.gitignore index 8cd7ec3..132a24f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,18 @@ +# ============================================================================= +# DO NOT RE-ENABLE TRACKING FOR CLAUDE.md OR docs/. +# +# CLAUDE.md and docs/ are LOCAL-ONLY by intentional decision (autonomous-mode +# user preference). Both files have been `git rm --cached` and remain on disk +# for local agent state. Do not add `!CLAUDE.md` or `!docs/...` exception +# lines below. Do not "helpfully" re-track these so cloud agents can read +# them. The user has reverted that pattern repeatedly. This rule is final. +# ============================================================================= + **/*.md !CHANGELOG.md -!CLAUDE.md -# WHY exception for docs/superpowers + CLAUDE.md: cloud agents -# (scheduled remote triggers) clone the repo fresh and need spec/plan -# artifacts + binding rules to continue autonomous work across -# sessions. Local WIP .md files elsewhere in the repo remain gitignored. -!docs/superpowers/**/*.md -!docs/routines/**/*.md +CLAUDE.md +docs/ +.claude !.claude/**/*.md # WHY: issue templates (.yml) and any future workflow docs live under # .github/; the blanket **/*.md rule would otherwise exclude markdown @@ -23,3 +29,11 @@ node_modules/ apps/desktop/dist/ apps/desktop/dist-types/ .codex +# WHY: Tauri externalBin sidecar binaries (ffmpeg + future helpers) are +# fetched at CI build time, named per-target-triple (e.g. +# `ffmpeg-x86_64-unknown-linux-gnu`), and can be 50-100 MB. Never commit +# them — they are platform-specific, large, and CI provisions them +# fresh each run. The `.gitkeep` is preserved so the directory exists +# at clone time (Tauri's externalBin resolver expects the path). +crates/desktop/binaries/* +!crates/desktop/binaries/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index d960875..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,252 +0,0 @@ -# perima — cross-platform media asset manager - -Rust hexagonal core + Tauri 2 / React desktop. Mobile (Expo + UniFFI) and -Obsidian (axum HTTP API) are downstream shells. Full rationale in -`2026-04-09-multiplatform-rust-perima.md` and -`2026-04-09-documentation-research.md` — read those before decisions. - -## Autonomous mode - -Developed without human confirmation. Never stop to ask; human speech is -steering, not interruption. Chip one task at a time. - -## Phase loop (repeat per phase/step) - -1. `superpowers:brainstorming` — intent, requirements, design. -2. Spec: write in working tree → reviewer subagent → revise → repeat - until clean → **one** `docs(specs):` commit. -3. `superpowers:writing-plans` — same working-tree loop → **one** - `docs(plans):` commit. -4. `superpowers:executing-plans` + `superpowers:subagent-driven-development`. -5. Per task: reviewer subagent (`superpowers:requesting-code-review`) - checks code vs spec + plan. -6. `superpowers:verification-before-completion` before claiming done. -7. `superpowers:test-driven-development` throughout. - -If you have just been compacted, load the skills appropriate for the current task. The most common and important ones: mid-execution - subagent-dr-dev. Pre: brainstorm. - -Correctness rides on tests (no human e2e feedback): unit + integration -always, e2e where feasible. Spec/plan drafts stay uncommitted during -review — collapses N revision commits into one clean commit and keeps -main from snapshotting half-reviewed intent as "current state". - -## Workspace layout (target) - -Flat `crates/` layout, virtual workspace manifest at root. `crates/core` -= domain + trait ports, zero framework deps. `crates/{db,fs,hash}` = -adapters. `crates/{desktop,api,cli,ffi}` wire adapters to core. -`apps/desktop` = React frontend (Vite + Tailwind). Build automation in -`justfile`; reintroduce `crates/xtask` only when shell scripts stop scaling. - -## Tooling -- JS/TS: **always `bun`** unless incompatible. -- Rust: `cargo`, `cargo clippy`, `cargo test`, `cargo doc`. `rusqlite` - (bundled), not sqlx. `thiserror` in libs, `anyhow` in apps. -- `justfile` at root (Rust `xtask` deferred). -- **`codebase-memory-mcp`** — perima is indexed (project name - `media-vboxuser-G-samsung1-0utoffiles-code-perima`). Tools: - `mcp__codebase-memory-mcp__{query_graph, trace_call_path, search_code, - search_graph, get_architecture, index_status}`. Reach for it on - "who calls X?", "what breaks if I rename Y?", "crates depending on - Z?", "find all Tauri command handlers". Skip for raw text in - docs/comments or known file paths. **Dispatch subagents with the MCP - note** — they inherit the tools; include "`mcp__codebase-memory-mcp__*` - available; prefer over grep for structural queries" in their prompt. -- LSP rust-analyzer — `ToolSearch select:LSP`. - Rust-only semantic symbol queries; prefer over grep for Rust names. - Grep last resort -- In doubt of api and implementations, use context7, deepwiki, and web search, to find latest docs. Things include: crates, ts/react libraries, best practices, test libraries, e2e, DBs, react practices and anti-patterns. - -## Test stack (pinned, don't re-litigate) -- Rust: **`cargo nextest run`** + `insta` (snapshots) + `proptest` (hash - determinism, path round-trip). Integration tests hit real SQLite. - Doctests still go through `cargo test --doc` (nextest doesn't run them). -- **NEVER use `cargo test` for non-doc tests** (banned repo-wide via - `scripts/no-cargo-test.sh`, enforced in `just ci` + lefthook pre-push). - WHY: SQLite has a lock-order inversion in `unixClose` (GLOBAL→PER-INODE) - vs `unixLock` from `sqlite3WalClose` (PER-INODE→GLOBAL) — concurrent - Connection drops on the same DB file deadlock. `cargo test` reproduces - it ~20% of runs (intermittent silent hang); `cargo nextest run`'s - process-per-test isolation eliminates it. See `RESEARCH-sqlite-deadlock.md` - + GH #121. Reproduced 2026-04-23 with two gdb backtraces. -- TS: `bun test` units; `vitest` + jsdom for React; Playwright only when - a phase ships UI worth e2e-ing. - -## Test timing baselines (cold compile, `-j 2`, as of 532ac2a / 2026-04-22) -Set subagent `timeout NNN` wrappers at **~2× expected**, not blanket 600s. -A too-loose timeout wastes 5-10 min per failed attempt. - -- `cargo nextest run -p perima-db --test-threads 2`: **~15s** (slowest single - test `fts_consistent_under_tag_churn` ~14s — proptest) -- `cargo nextest run -p perima-app --test-threads 2`: **~5-10s** (23 tests) -- `cargo nextest run -p perima-cli --test-threads 2`: **<10s** -- `cargo nextest run --workspace --exclude perima-desktop -j 2`: **<60s** clean -- `cargo nextest run -p perima-desktop`: **120-300s** (Tauri compile dominates) -- `cargo clippy --workspace --exclude perima-desktop -j 2 -- -D warnings`: **30-90s** - -Nextest 0.9.133 (latest as of 2026-04-22) lacks a `--slow-timeout` CLI -flag, but honors the equivalent config key. `.config/nextest.toml` -committed at repo root with `[profile.default] slow-timeout = { period -= "60s", terminate-after = 2 }` — any test past 120s self-aborts so -outer `timeout NNN` wrappers don't kill the whole suite on one hang. - -## Long-running commands -- Wrap workspace-wide `cargo {nextest,build,run}` in `timeout 600`. Trip = deadlock signal, NOT retry trigger. -- **`cargo nextest run` is mandatory** for non-doc tests (see Test stack above + `scripts/no-cargo-test.sh`). Hung test self-terminates via `.config/nextest.toml` slow-timeout; suite continues. Doctests still need `cargo test --doc`. -- Bash output silent >3min + `ps` shows 0% CPU with all threads on `futex_do_wait`/`ep_poll` = deadlock. Kill, `gdb -p -batch -ex "thread apply all bt 20"` for backtrace, fix root cause. -- Include this rule verbatim in every subagent dispatch that runs tests — they read CLAUDE.md but forget rules under load. - -## Doc discipline (strict, enforced in CI) -- Rust: `#![deny(rustdoc::broken_intra_doc_links)]` + - `#![warn(missing_docs)]` workspace-wide. -- Clippy: `-D warnings` + `clippy::cognitive_complexity`, - `clippy::too_many_lines`, `clippy::excessive_nesting`. Cyclomatic <10, - cognitive <15. -- `kani` on curated `#[kani::proof]` invariants (hashing, path norm); - `just verify` + nightly CI only, never per-commit. -- TS: `eslint-plugin-tsdoc`, TypeDoc `--validation.notDocumented`. -- Every non-obvious decision gets a `// WHY:` comment. -- Architectural decisions: CLAUDE.md (rules) + phase specs (intent) + - commit WHY blocks (ground truth). No separate DECISIONS.md. -- Unified doc site: Astro Starlight (Tauri-aligned, MDX polyglot). - Rust doctests via `mdbook test`. `cargo doc` + TypeDoc feed Starlight - in a later phase. - -## Schema rules (CRDT-ready from day one) -UUIDv7 PKs, `updated_at` + `device_id` on every mutable row, soft -deletes, no UNIQUE on mutable columns, no FK cascades. Every FTS / -search aggregation MUST filter `deleted_at IS NULL` on every joined -table (link + entity, e.g. both `file_tags` AND `tags`); every -trigger touching a soft-deletable table MUST handle both transition -directions (soft-delete AND restore). V007→V008 bug class. - -## Git -- All work on `main`. No branches, no worktrees. -- **Releases = semver tags** (`v0.N.x`). No fixed v1.0.0. "Phase" is - internal vocabulary only — never in tags, commits, or CHANGELOG. -- **Conventional Commits** with component scopes (`core`, `db`, `fs`, - `hash`, `cli`, `desktop`, `ci`, `deps`, `docs`, `release`). - `release-plz` handles bumps + CHANGELOG from v0.4.0 on. -- Commit order: execute → tests green → reviewer green → commit. -- `**/*.md` gitignored except `CHANGELOG.md` + `docs/superpowers/**/*.md` - (tracked so cloud agents can continue across sessions). -- New commits only; no amend, no `--no-verify`. - -## Model defaults -Opus 4.6 for planning/review & complex coding. Sonnet for bulk/simple execution; Haiku 4.5 for trivial lookups. **Never include `Co-Authored-By:` in commits.** - -## Architecture conventions (evolving — see arch-audit umbrella spec) - -These sections are incrementally populated as Batches B-J land. Cross-ref: AGENTS.md for library/framework pins (different scope). - -### Crate topology (target post-Batch-B) - -- `crates/core` — domain types + trait ports, zero framework deps. -- `crates/app` — UseCase structs with `Arc` fields (Batch B). -- `crates/{db,fs,hash,media}` — adapters. -- `crates/{cli,desktop}` + future `api` + `ffi` — shell binaries, wire-up only. - -### Port traits - -- Repositories take `&self`, not `&mut self` (A2, landed `081c694` + `333c4b6`). -- Interior mutability lives in the adapter. -- No generic parameters on service structs; use `Arc` fields. - -### UseCase pattern (Batch B landed 2026-04-21) - -- Concrete `struct XxUseCase` with `Arc` fields; zero generics. -- Single `pub async fn execute(&self, cmd: XxCommand) -> Result`. -- One UseCase per user-visible workflow (not per entity). Current set: `Scan`, `Search`, `Tag`, `Volume`, `Metadata`. -- `AppContainer` is `#[derive(Clone)]`; fields are `Arc` + `Arc` (named `events`). -- `AppContainer::new(deps: AppDeps, handlers: Vec>)` is the ONLY `CompositeEventBus::new` construction site in production code. Adding a second is a regression. -- Shells build one container per process: desktop via Tauri `.setup(...)`, CLI via `build_container(db_path, extra_handlers)`. Shell-specific event handlers (Tauri emitter, db writeback) enter as `extra_handlers`. -- `LogEventHandler` lives in `perima_app::telemetry`. `DbEventHandler` stays shell-local in desktop (coupled to `SqliteFileRepository`). -- `CoreError::Internal(String)` wraps orchestration-layer errors until Batch D's typed `CoreError` discriminated union lands. -- Watch (filesystem watcher) is shell-local by design (`crates/cli/src/cmd/watch.rs` + `crates/desktop/src/commands.rs::{start,stop,is}_watch`); both consume `state.container.events` for emission. Tracked in #120. -- New write paths do NOT populate `hlc` in Batch B — deferred to Batch C's writer actor where the single SQL entry point can generate the HLC once per command. -- Post-Batch-B narrowing of shell-side residuals (volume filter arg, `TagOutput::Attached` widening, `detach by id`, `VolumeCommand::FindOrCreate`, `write_manifest` home) tracked on #119. - -### Connection model (Batch C landed 2026-04-22) - -- One `SqliteWriter` actor per process, on a `std::thread::spawn` OS thread. Owns the sole writable `rusqlite::Connection` for its lifetime. Writer is an OS thread, NOT a second tokio runtime — one-runtime-per-binary rule preserved. (Shells currently hold auxiliary writer handles for pre-container construction ordering — CLI 3 sites, desktop 2 sites; tracked in #125 for consolidation, not a blocker.) -- Writes: adapter methods build `WriteCmd::*` variants with `flume::bounded(1)` reply channels, `writer.send(cmd)` (sync), `reply_rx.recv()` (sync). Port traits stay sync; UseCase `execute` methods are async for forward-compat but hold no `.await` on the repo call. -- One HLC value per `WriteCmd` (== one user-visible logical event), stamped on every HLC-bearing row the command writes. Two WriteCmds in the same millisecond get distinct HLCs via the process-global counter in `HLC_STATE`. -- Reads: `r2d2::Pool`, size 4, `SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX`, `with_init` pragmas (`busy_timeout=5000`, `temp_store=MEMORY`, `mmap_size=268435456`, `query_only=1`). -- Migrations: Refinery, run ONCE at startup inside `SqliteWriter::start`, synchronously, on the connection that becomes the writer. No per-command migration sniff. -- Domain events publish AFTER `COMMIT` from inside the writer. Handlers return `Result<(Out, Vec), CoreError>`; writer fans out via `Arc` passed from `AppContainer::new`. Rollback → zero events. Failed `emit()` logs via `tracing::warn!`, does not abort other handlers. Batch C handlers currently return empty event vecs — scaffolding in place for Batch E. -- Writer receives `Arc` from `AppContainer::new`. `crates/db` does NOT construct `CompositeEventBus`. Preserves Batch B's single-construction-site invariant. -- No `open_and_migrate` callsite in production code under `crates/{cli,desktop,app}/src/**` (non-`#[cfg(test)]`) outside `SqliteWriter::start`. The function itself stays in `crates/db/src/connection.rs` as the writer's migration primitive. -- `SqliteFileRepository` and `SqliteSearchRepository` (and the other three adapters) are `{ writer: Sender, reads: ReadPool }` only — no `Inner::Legacy` / `new_legacy`. Re-introducing that enum arm or constructor is a regression. -- Port traits in `crates/core/src/ports/*.rs` unchanged — sync `&self` methods, `Send + Sync` adapter bounds. -- **Why `r2d2_sqlite` over `deadpool-sqlite`:** port traits are sync; `deadpool-sqlite`'s async `interact` API would force async ports (out-of-batch refactor). Library-audit §Q1 deferred the pool pick; Batch C resolved to `r2d2_sqlite 0.32`. -- **Why `flume` for reply channels:** `tokio::sync::oneshot::Receiver::blocking_recv` panics inside a tokio runtime context; `flume::Receiver::recv` is runtime-agnostic. Single dep covers command channel + reply channels. -- FTS5 proptests (`fts_consistent_under_tag_churn`, `fts_matches_ground_truth_under_soft_delete_churn`) are capped at 64 / 32 cases respectively. Raising the cap without an FTS-oracle rewrite is a regression (#124). - -### IPC boundary contract (Batch D landed 2026-04-22) - -- Tauri commands return `Result`. NO handler returns `Result`. NO handler calls `.map_err(|e| e.to_string())`. -- `CoreError` lives in `crates/core::errors`. It derives `thiserror::Error + Serialize + Clone` always, and `specta::Type` behind the `specta` feature. `#[serde(tag = "kind", content = "data")]` produces a TypeScript discriminated union. -- `CoreError::Io { kind, message }` lowers `std::io::Error::kind()` (e.g. `"NotFound"`, `"PermissionDenied"`) to a string + the display message. Lossy by design — `std::io::Error` is `!Serialize`. Capture both fields, never just the message. Construct via `CoreError::from(io_err)` (single `From` impl in `crates/core/src/errors.rs`); `?`-propagation works through it. -- `crates/desktop` enables `perima-core/specta` and `perima-app/specta`. `crates/cli` does NOT — `crates/core` and `crates/app` stay framework-dep-free in CLI builds (verified: `cargo tree -i specta -p perima` is empty). -- `bindings.ts` is committed to git at `apps/desktop/src/bindings.ts`. It is generated by `tauri-specta` when `crates/desktop` is built with `--features specta-export`. CI runs `just bindings` (= `cargo build -p perima-desktop --features specta-export && git diff --exit-code apps/desktop/src/bindings.ts`) — drift fails the job. Local pre-push verification: `just bindings`. -- Production release builds do NOT enable `specta-export` (no surprise fs writes into the frontend source tree). -- `apps/desktop/src/types.ts` does NOT exist. Re-introducing it is a regression. All TS types come from `./bindings`. -- `apps/desktop/src/api.ts::fromInvoke` returns `ResultAsync` via `ResultAsync.fromPromise(invoke(...), parseCoreError)`. `parseCoreError` falls back to `{ kind: "Internal", data: }` for non-CoreError rejections. -- Re-introducing per-shell wire-type structs that mirror core types 1:1 is a regression — derive `specta::Type` on the core type instead. The exception is shell-private composite payloads that have no clean core analogue: `crates/desktop/src/payloads.rs` retains `FileWithMetadataPayload` (deliberate 17-field flat shape for grid binding) and `FileWithTagsPayload` (`#[serde(flatten)]` composition). Adding more shell-side composites is allowed only when the same 1:1-vs-composite decision is documented inline. -- The 10 specta-derived core types: `BlakeHash`, `FileSize`, `MediaPath`, `VolumeId`, `FileLocationRecord`, `VolumeRecord` (in `crates/core/src/types.rs`); `MediaMetadata`, `Tag`, `SearchHit`, `FileEvent` (one per file, same crate). `ScanReport` + `ScanReportEntry` from `crates/app/src/scan.rs` are also bindings-emitted (with `#[serde(skip)]` on shell-internal routing fields). Adding a new domain type that crosses the IPC boundary requires `#[cfg_attr(feature = "specta", derive(specta::Type))]` + `Serialize` on the type itself, NOT a wire-type mirror. -- `FileEvent` uses `#[serde(tag = "type")]` INLINE (no content key) to stay byte-compatible with the pre-Batch-D `FileEventPayload` channel contract; CoreError uses `#[serde(tag = "kind", content = "data")]`. Different shapes by design — CoreError is a Result error type, FileEvent is a v1-frozen channel payload. -- Adding a new `CoreError` variant: append to the enum with `#[error("…")]` + verify `bindings.ts` regenerates + extend frontend `parseCoreError`'s `KNOWN_KINDS` set + extend the `switch (err.kind)` block in `apps/desktop/src/components/StatusBar.tsx` (TypeScript exhaustiveness `never` default makes this a compile-error if missed). -- At least one frontend component (`StatusBar.tsx`) pattern-matches on `err.kind` with a TypeScript-exhaustive `never` default, proving the typed error flows end-to-end. Other components stringify via `apps/desktop/src/lib/coreError.ts::coreErrorMessage` until Batch H rewrites the toast/banner UX. - -### Event bus (Batch E landed 2026-04-23) - -- `EventBus` trait lives in `crates/core::events` as the publisher port. Single method: `fn emit(&self, event: &AppEvent) -> Result<(), CoreError>`. Sync — writer thread (Batch C) is not on a tokio runtime. -- `AppEvent` enum (in `crates/core::events`) has 3 variants: - - `File(FileEvent)` — wraps the existing filesystem-watcher event. - - `ScanCompleted { volume, files_seen, files_new, duration_ms }` — published by `ScanUseCase::execute` after a successful scan. - - `IndexInvalidated { reason: InvalidationReason }` — published by the writer actor after every `WriteCmd` that changes a query-relevant index. Reasons: `TagsChanged`, `FilesChanged`, `MetadataChanged`, `SearchIndexRebuilt`. -- `AppEvent` derives `Serialize + Clone` always; `specta::Type` behind the `specta` feature. `#[serde(tag = "kind", content = "data")]` produces the TypeScript discriminated union the frontend pattern-matches on. -- The concrete `Bus` lives in `crates/app::bus` and is the SOLE production impl of `EventBus`. Built once in `AppContainer::new`. Uses `async_broadcast::Sender` (capacity 256, backpressure mode = default — `set_overflow(false)`). Re-introducing `CompositeEventBus` is a regression. -- Handlers implement `EventHandler` (in `crates/app::events`) — `async fn handle(&mut self, event: AppEvent)`. Three production impls: `LogEventHandler` (`crates/app::telemetry`), `DbEventHandler` (shell-local: CLI's `crates/cli/src/cmd/watch.rs`, desktop's `crates/desktop/src/commands.rs`), `TauriEventHandler` (`crates/desktop/src/events.rs`). -- Handler tasks are spawned by `AppContainer::new` from a `Vec>` parameter. Each task owns its own `Receiver` and runs a `recv_loop` that handles `Overflowed` (lag → log warn + continue) and `Closed` (shutdown → exit). Panics inside `handle` are caught + logged; the loop continues. -- `Bus::emit` is sync `try_broadcast`. On `Full` (slow subscriber): `tracing::warn!` + return Ok — the writer is best-effort. Capacity 256 + fast-handler invariant means Full should not fire under normal load. -- `tokio::sync::broadcast` is BANNED. It silently drops on lag. Use `async_broadcast::broadcast` (default = backpressure mode). -- Tauri channel: ONE channel `"app-event"` carries `AppEvent`. The previous `"file-event"` channel is gone. Frontend `apps/desktop/src/api.ts::subscribeToAppEvents` is the single subscription point. -- The writer's per-`WriteCmd` handler returns `Result<(Out, Vec), CoreError>`. Empty Vec = no event. Each `IndexInvalidated::*` reason maps to a category of writes per the spec — adding a new write type that affects a query requires populating the Vec. -- `NoopBus` (`crates/db::test_utils::noop_bus`) is the test stub. Used by writer tests that don't care about events. `MockEventBus` (in `crates/fs/src/watcher.rs::tests`) collects events for assertion. Both impl the `EventBus` trait. -- Adding a new `AppEvent` variant: append to enum + add `#[serde(...)]` tag if needed + verify `bindings.ts` regenerates + extend frontend `App.tsx` switch on `event.kind` (no exhaustiveness check today — a future Batch H refactor may add one). -- Adding a new `EventHandler`: impl the trait + add to the shell's handler `Vec` passed into `AppContainer::new`. - -### Frontend state (Batch H will fill in) - -Stub: three-layer split. TanStack Query v5 for server-state via `bindings.ts` commands. Zustand 5 for global UI state. `useState` for component-local only. TanStack Router with `createHashHistory`. Cache invalidation driven by `AppEvent` listener, never by Rust-side `invalidate_query!`-style macros. React Compiler 1.0 is on (L2 landed); do not write manual `useMemo`/`useCallback`. Details in Batch H spec. - -### Observability (Batch I will fill in) - -Stub: `tracing` + `tracing-subscriber` JSON in release; `#[tracing::instrument]` on every `UseCase::execute`; `perima --debug-report` dumps last N traced events. Details in Batch I spec. - -### Tokio runtime policy - -- One tokio runtime per binary. Never spawn a second. -- Desktop: Tauri owns the runtime. -- CLI: `#[tokio::main]`. -- Tests: `#[tokio::test]` or single-threaded runtime per test. -- Writer actor (Batch C) is a dedicated OS thread, NOT a second runtime. - -### Error handling - -- `thiserror` in lib crates (`crates/core`, `db`, `fs`, `hash`, `media`, future `app`). -- `miette` with `fancy` feature in binary crates ONLY (`crates/cli`, `crates/desktop`) — landed L7 (`938eccc`). -- `CoreError` lives in `crates/core::errors`. It does NOT derive `miette::Diagnostic` — binary-UX-only concern that stays at the binary boundary. Batch D will add `#[derive(Serialize, specta::Type)]` + `#[serde(tag = "kind", content = "data")]` behind the `specta` feature. -- `anyhow` is rejected in `crates/cli` + `crates/desktop` declared deps (L7 removed it); transitive via deps is acceptable. -- `color-eyre` is rejected (archived upstream). - -### Schema rules expansion (HLC + shared_doc — Batch A) - -- Every mutable **user-editable + sync-eligible** row carries an `hlc INTEGER` column alongside `updated_at` (v1 posture: nullable, populated in Batch B+). -- Device-local rows (`volume_mounts`, `scan_progress`, `thumbnail_queue`, `device_config`) do NOT carry `hlc` — their identity is machine-scoped and they never sync. -- `shared_doc` table is reserved empty for post-v1 Loro integration. Do NOT add rows in v1. Do NOT add `loro` crate. -- HLC packing: 48 low-bits ms + 16 high-bits counter → non-negative i64. `crates/core::Hlc` provides `now()` + `pack()` + `unpack()`. -- Existing rules (unchanged): UUIDv7 PKs, `updated_at` + `device_id` on every mutable row, soft deletes, no UNIQUE on mutable columns, no FK cascades, FTS filters + triggers respect soft-delete + restore (V007→V008 bug class). - -Note: for now, dont commit plans, specs, claude.md updates until the human says otherwise. diff --git a/Cargo.lock b/Cargo.lock index da1dd00..ebc48a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,16 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "assert_cmd" version = "2.2.1" @@ -222,6 +232,45 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-openai" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafa6acfa9d5138539abe815de90b0a4b7127420e6846c71bb23cf68660641ba" +dependencies = [ + "async-openai-macros", + "backoff", + "base64 0.22.1", + "bytes", + "derive_builder", + "eventsource-stream", + "futures", + "getrandom 0.3.4", + "rand 0.9.4", + "reqwest", + "secrecy", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "async-openai-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81872a8e595e8ceceab71c6ba1f9078e313b452a1e31934e6763ef5d308705e4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -311,6 +360,20 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.17", + "instant", + "pin-project-lite", + "rand 0.8.6", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -761,6 +824,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -786,6 +850,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -809,7 +883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -822,7 +896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -910,9 +984,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1000,14 +1074,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[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.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1023,17 +1121,67 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[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.117", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deranged" version = "0.5.8" @@ -1044,6 +1192,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1078,6 +1257,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -1262,6 +1453,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equator" version = "0.4.2" @@ -1330,6 +1530,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom 7.1.3", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1978,6 +2189,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2061,6 +2291,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -2128,6 +2364,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -2138,9 +2380,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2426,6 +2670,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2453,6 +2706,15 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -2464,6 +2726,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_ci" version = "1.2.0" @@ -2632,6 +2904,22 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2712,6 +3000,15 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.12" @@ -2758,6 +3055,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3265,6 +3572,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3448,12 +3765,34 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "open" +version = "5.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owo-colors" version = "4.3.0" @@ -3526,6 +3865,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3537,13 +3882,18 @@ name = "perima" version = "0.6.4" dependencies = [ "assert_cmd", + "async-broadcast", "async-trait", "chrono", "clap", "ctrlc", + "dialoguer", "directories", + "flume", + "hound", "image", "insta", + "keyring", "miette", "nix", "perima-app", @@ -3552,8 +3902,10 @@ dependencies = [ "perima-fs", "perima-hash", "perima-media", + "perima-transcribe", "predicates", "rayon", + "regex", "rusqlite", "serde", "serde_json", @@ -3562,6 +3914,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "wiremock", ] [[package]] @@ -3572,13 +3925,16 @@ dependencies = [ "async-trait", "chrono", "directories", + "flume", "futures", "insta", + "keyring", "perima-core", "perima-db", "perima-fs", "perima-hash", "perima-media", + "perima-transcribe", "proptest", "rayon", "rusqlite", @@ -3588,6 +3944,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -3605,6 +3962,7 @@ dependencies = [ "specta", "tempfile", "thiserror 2.0.18", + "tokio-util", "unicode-normalization", "uuid", ] @@ -3646,6 +4004,7 @@ dependencies = [ "chrono", "directories", "image", + "keyring", "miette", "perima-app", "perima-core", @@ -3653,6 +4012,7 @@ dependencies = [ "perima-fs", "perima-hash", "perima-media", + "perima-transcribe", "rayon", "serde", "serde_json", @@ -3661,6 +4021,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-shell", "tauri-specta", "tempfile", "tokio", @@ -3668,6 +4029,7 @@ dependencies = [ "tracing", "tracing-appender", "uuid", + "which", ] [[package]] @@ -3723,6 +4085,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "perima-transcribe" +version = "0.6.4" +dependencies = [ + "async-openai", + "flume", + "hound", + "keyring", + "mime_guess", + "perima-core", + "proptest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "which", + "wiremock", +] + [[package]] name = "phf" version = "0.8.0" @@ -3924,9 +4309,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -4193,9 +4578,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -4581,10 +4966,12 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-util", @@ -4705,6 +5092,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4780,6 +5173,52 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4922,6 +5361,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -4947,7 +5398,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5014,12 +5465,50 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5390,7 +5879,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -5612,6 +6101,27 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5687,7 +6197,7 @@ version = "2.0.0-rc.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e23657d20f2b5508d5eca5ee6bb98d77e4c13127b9049e102b5db6a63bc73665" dependencies = [ - "darling", + "darling 0.23.0", "heck 0.5.0", "proc-macro2", "quote", @@ -5960,6 +6470,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6001,6 +6522,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -6767,6 +7303,15 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7280,6 +7825,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7515,6 +8083,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 99bfd15..7913640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,15 @@ cognitive_complexity = "warn" too_many_lines = "warn" excessive_nesting = "warn" module_name_repetitions = "allow" +# WHY allow: `doc_markdown` demands backticks around any mixed-case token it +# mistakes for an identifier — "OpenAI", "JavaScript", "GitHub", product names, +# vendor names. It fires constantly on ordinary English prose in doc comments, +# every fire is a pure-churn edit with no correctness value, and clippy's +# default `doc-valid-idents` allowlist can't keep up with the proper nouns this +# codebase actually uses. Real doc-quality enforcement lives in +# `rustdoc::broken_intra_doc_links` (deny) + `missing_docs` (deny), which catch +# links and coverage — the things that actually break readers. +doc_markdown = "allow" missing_errors_doc = "warn" # --- Restriction lints (wave 1, 2026-04-20) --- # Zero fires on HEAD; pure forward-insurance against AI-slop patterns. @@ -102,6 +111,39 @@ nom-exif = "2.7" mp4parse = "0.17" mime_guess = "2" minijinja = { version = "2", default-features = false, features = ["macros", "loader"] } +# WHY async-openai 0.36 (plan spec'd 0.32; 0.36 is latest stable as of 2026-05): +# The `audio` feature gates the Transcriptions/Speech/Translations API groups; +# default-features disabled to avoid pulling in `assistant` + `batch` + etc. +async-openai = { version = "0.36", default-features = false, features = ["audio"] } +# WHY which 8: cross-platform PATH binary discovery for CliFfmpegInvoker. +which = "8" +# WHY keyring 3.6 (not 4.0): keyring 4.0 removed the compile-time feature flags +# (apple-native, windows-native, linux-native-sync-persistent) in favour of +# runtime `use_native_store()` calls — a breaking API change. The T4 adapter +# slice targets the 3.6 feature-flag API; upgrading to 4.0 is a separate +# migration tracked alongside T4. +keyring = { version = "3.6", features = ["apple-native", "windows-native", "linux-native-sync-persistent"] } +# WHY dialoguer 0.12: interactive CLI prompts; `password` gates the hidden-input +# widget for API key entry. +dialoguer = { version = "0.12", features = ["password"] } +# WHY wiremock 0.6 (plan spec'd 0.7; 0.6.5 is latest stable as of 2026-05, 0.7 +# does not exist on crates.io): HTTP mock server for openai-compat tests. +wiremock = "0.6" +# WHY hound 3.5: WAV read/write used by audio fixture helpers in tests. +hound = "3.5" +# WHY regex 1: BCP-47 language-code shape validation in CLI `transcribe --language`. +# Loose validator (RFC 5646 subset); promote to `unic-langid` if/when full +# Unicode-language-tag handling is needed. +regex = "1" +# WHY toml 1 (plan spec'd 0.10; 1.1 is latest stable as of 2026-05): used by +# transcription config loader in the adapters slice. +toml = "1" +# WHY tauri-plugin-shell 2: needed by desktop shell for DesktopFfmpegInvoker +# to locate the bundled sidecar binary path. +tauri-plugin-shell = "2" +# WHY ffmpeg-sidecar 2: workspace dep so desktop crate can reference the +# sidecar binary management helpers without re-pinning. +ffmpeg-sidecar = "2" [profile.release] lto = "thin" diff --git a/apps/desktop/src/__tests__/FileGrid.test.tsx b/apps/desktop/src/__tests__/FileGrid.test.tsx index 9605297..24d9f88 100644 --- a/apps/desktop/src/__tests__/FileGrid.test.tsx +++ b/apps/desktop/src/__tests__/FileGrid.test.tsx @@ -32,6 +32,7 @@ function makeFile(overrides: Partial): FileWithTagsPayload mime_type: null, thumbnail_path: null, thumbnail_status: null, + absolute_path: "/mnt/test/photos/example.jpg", tags: [], ...overrides, }; diff --git a/apps/desktop/src/__tests__/FileSidebar.test.tsx b/apps/desktop/src/__tests__/FileSidebar.test.tsx index daa3895..1eb61b0 100644 --- a/apps/desktop/src/__tests__/FileSidebar.test.tsx +++ b/apps/desktop/src/__tests__/FileSidebar.test.tsx @@ -61,6 +61,7 @@ function makeFile(overrides?: Partial): FileWithTagsPayload mime_type: null, thumbnail_path: null, thumbnail_status: null, + absolute_path: "/mnt/test/photos/img_1.jpg", tags: [], ...overrides, }; diff --git a/apps/desktop/src/__tests__/FileTable.test.tsx b/apps/desktop/src/__tests__/FileTable.test.tsx index 19a9caf..d552fb3 100644 --- a/apps/desktop/src/__tests__/FileTable.test.tsx +++ b/apps/desktop/src/__tests__/FileTable.test.tsx @@ -9,6 +9,7 @@ const makeEntry = (n: number): FileWithTagsPayload => ({ // per-fixture so the table doesn't collapse rows on a duplicate key. file_uuid: `00000000-0000-0000-0000-${String(n).padStart(12, "0")}`, hash: "a".repeat(62) + String(n).padStart(2, "0"), + quick_hash: null, size: 1024 * n, volume_id: "00000000-0000-0000-0000-00000000000" + n, relative_path: `/photos/img_${n}.jpg`, @@ -25,6 +26,7 @@ const makeEntry = (n: number): FileWithTagsPayload => ({ mime_type: null, thumbnail_path: null, thumbnail_status: null, + absolute_path: `/mnt/test/photos/img_${n}.jpg`, tags: [], }); diff --git a/apps/desktop/src/__tests__/TranscribeButton.test.tsx b/apps/desktop/src/__tests__/TranscribeButton.test.tsx new file mode 100644 index 0000000..cf51a0e --- /dev/null +++ b/apps/desktop/src/__tests__/TranscribeButton.test.tsx @@ -0,0 +1,256 @@ +/** + * TranscribeButton — component tests. + * + * 7 scenarios: + * 1. Idle render — no job in slice → button shows "Transcribe", click calls api.transcribe. + * 2. Queued render — slice has job with status.kind === "queued" → shows queue position, + * click calls api.cancelTranscription. + * 3. Running render — slice has job with processed_ms/total_ms → shows percent. + * 4. Running render with no total_ms — shows "..." instead of percent. + * 5. Completed render — button is disabled, shows "Done!". + * 6. Failed render — button shows "Retry", has destructive variant, click resets to Idle attempt. + * 7. api.transcribe error — mock to reject with QueueFull → notifyError called. + * + * WHY vi.mock("../api"): api wrappers invoke window.__TAURI__ IPC; mocking avoids + * wiring a real Tauri context in jsdom. + * + * WHY act + startJob (not seedJob before render): renderWithProviders calls + * resetUiStore() internally, which zeroes the jobs map. Seeding AFTER render + * via act() lets the component re-render with the seeded state synchronously, + * without needing a round-trip through waitFor. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { okAsync, errAsync } from "neverthrow"; +import { TranscribeButton } from "../components/TranscribeButton"; +import * as api from "../api"; +import { useUiStore } from "../stores/ui"; +import { renderWithProviders, resetUiStore } from "./test-utils"; +import type { CoreError, TranscribeStartedPayload } from "../bindings"; +import type { TranscriptionJob } from "../stores/ui"; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { + ...actual, + transcribe: vi.fn(), + cancelTranscription: vi.fn(), + }; +}); + +const mockTranscribe = vi.mocked(api.transcribe); +const mockCancelTranscription = vi.mocked(api.cancelTranscription); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +const FILE_UUID = "file-uuid-0001"; +const FILE_NAME = "vacation.mp4"; +const FILE_SOURCE = "videos/vacation.mp4"; + +const defaultProps = { + fileUuid: FILE_UUID, + fileName: FILE_NAME, + source: FILE_SOURCE, +}; + +function makeJob(overrides: Partial = {}): TranscriptionJob { + return { + request_uuid: "req-0001", + file_uuid: FILE_UUID, + file_name: FILE_NAME, + status: { kind: "queued", queue_position: 1 }, + started_at_ms: 1_700_000_000_000, + ...overrides, + }; +} + +/** + * Seed a job into the live Zustand store via the slice's own `startJob` action. + * Must be wrapped in `act()` so React processes the re-render synchronously. + * + * WHY startJob (not raw setState with jobs object): `startJob` is the slice's + * canonical mutator — avoids overwriting action closures while preserving + * full type safety on the `TranscriptionJob` shape. + */ +function seedJob(job: TranscriptionJob) { + useUiStore.getState().transcription.startJob(job); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + resetUiStore(); +}); + +describe("TranscribeButton", () => { + // 0. Idle render with null source — T9 review fix + it("Idle: source === null disables the button with a 'volume not mounted' tooltip", () => { + renderWithProviders(); + + const btn = screen.getByRole("button", { name: /transcribe/i }); + expect(btn).toBeDisabled(); + expect(btn.getAttribute("title")).toMatch(/volume not mounted/i); + + // Even firing a click must not invoke the api (jsdom dispatches click on + // disabled buttons; the React handler is wired but guards on `source`). + fireEvent.click(btn); + expect(mockTranscribe).not.toHaveBeenCalled(); + }); + + // 1. Idle render + it("Idle: shows 'Transcribe' and calls api.transcribe on click", async () => { + const started: TranscribeStartedPayload = { request_uuid: "req-new", queue_position: 1 }; + mockTranscribe.mockReturnValue(okAsync(started)); + + renderWithProviders(); + + const btn = screen.getByRole("button", { name: /transcribe/i }); + expect(btn).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + + fireEvent.click(btn); + + await waitFor(() => { + expect(mockTranscribe).toHaveBeenCalledOnce(); + }); + expect(mockTranscribe).toHaveBeenCalledWith({ + fileUuid: FILE_UUID, + fileName: FILE_NAME, + source: FILE_SOURCE, + languageHint: null, + }); + }); + + // 2. Queued render + it("Queued: shows queue position and calls api.cancelTranscription on click", async () => { + const job = makeJob({ + request_uuid: "req-q", + status: { kind: "queued", queue_position: 3 }, + }); + mockCancelTranscription.mockReturnValue(okAsync(undefined)); + + renderWithProviders(); + // Seed job AFTER render (renderWithProviders resets the store first). + act(() => { seedJob(job); }); + + const btn = screen.getByRole("button"); + expect(btn.textContent).toMatch(/queued.*3|#3/i); + + fireEvent.click(btn); + + await waitFor(() => { + expect(mockCancelTranscription).toHaveBeenCalledOnce(); + }); + expect(mockCancelTranscription).toHaveBeenCalledWith("req-q"); + }); + + // 3. Running with percent + it("Running: shows percent when total_ms is present", () => { + const job = makeJob({ + status: { kind: "running", processed_ms: 3200, total_ms: 10_000 }, + }); + + renderWithProviders(); + act(() => { seedJob(job); }); + + const btn = screen.getByRole("button"); + // 3200 / 10000 * 100 = 32% + expect(btn.textContent).toMatch(/32\s*%/); + }); + + // 4. Running without total_ms + it("Running: shows '...' when total_ms is null", () => { + const job = makeJob({ + status: { kind: "running", processed_ms: 500, total_ms: null }, + }); + + renderWithProviders(); + act(() => { seedJob(job); }); + + const btn = screen.getByRole("button"); + expect(btn.textContent).toMatch(/\.\.\./); + // Should NOT contain a numeric percent value + expect(btn.textContent).not.toMatch(/\d+\s*%/); + }); + + // 5. Completed render + it("Completed: button is disabled and shows 'Done!'", () => { + const job = makeJob({ + status: { + kind: "completed", + transcript_id: "tx-001", + segment_count: 5, + language: "en", + }, + }); + + renderWithProviders(); + act(() => { seedJob(job); }); + + const btn = screen.getByRole("button"); + expect(btn).toBeDisabled(); + expect(btn.textContent).toMatch(/done/i); + }); + + // 6. Failed render + it("Failed: shows 'Retry', has destructive styling, click calls api.transcribe again", async () => { + const started: TranscribeStartedPayload = { request_uuid: "req-retry", queue_position: 1 }; + mockTranscribe.mockReturnValue(okAsync(started)); + + const job = makeJob({ + request_uuid: "req-fail", + status: { kind: "failed", error: { kind: "Auth" } }, + }); + + renderWithProviders(); + act(() => { seedJob(job); }); + + const btn = screen.getByRole("button"); + expect(btn.textContent).toMatch(/retry/i); + // WHY data-variant check: we assert the destructive variant is applied via + // data-variant attribute (no shadcn; we carry it explicitly for testability). + expect(btn.dataset["variant"]).toBe("destructive"); + expect(btn).not.toBeDisabled(); + + fireEvent.click(btn); + + await waitFor(() => { + expect(mockTranscribe).toHaveBeenCalledOnce(); + }); + expect(mockTranscribe).toHaveBeenCalledWith({ + fileUuid: FILE_UUID, + fileName: FILE_NAME, + source: FILE_SOURCE, + languageHint: null, + }); + }); + + // 7. api.transcribe error → notifyError + it("api.transcribe QueueFull error → notifyError fires with friendly message", async () => { + const err: CoreError = { + kind: "Transcription", + data: { kind: "QueueFull", data: { queued: 5 } }, + }; + mockTranscribe.mockReturnValue(errAsync(err)); + + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /transcribe/i })); + + await waitFor(() => { + expect(mockTranscribe).toHaveBeenCalledOnce(); + }); + + // notifyError appends a notification to the store. + await waitFor(() => { + const notifications = useUiStore.getState().notifications; + expect(notifications).toHaveLength(1); + expect(notifications[0]?.kind).toBe("error"); + // coreErrorMessage surfaces the QueueFull message. + expect(notifications[0]?.message).toMatch(/queue/i); + }); + }); +}); diff --git a/apps/desktop/src/__tests__/TranscribeSettingsModal.test.tsx b/apps/desktop/src/__tests__/TranscribeSettingsModal.test.tsx new file mode 100644 index 0000000..59bc191 --- /dev/null +++ b/apps/desktop/src/__tests__/TranscribeSettingsModal.test.tsx @@ -0,0 +1,365 @@ +/** + * TranscribeSettingsModal — component tests. + * + * 8 scenarios: + * 1. Renders form when open: true — provider input, key field, Save/Cancel visible. + * 2. Renders nothing when open: false — modal hidden. + * 3. Save flow happy path — fill form, click Save → setProviderKey + updateTranscriptionConfig + * called → onClose fired → providers query invalidated. + * 4. Save with empty API key on edit — only updateTranscriptionConfig called (key not re-saved). + * 5. Mid-save disable — click Save → Cancel disabled, Save button shows "Saving…". + * 6. Error inline banner — setProviderKey rejects with Auth → banner shows auth error message. + * 7. ESC key during save is no-op — fire ESC during pending mutation → onClose NOT called. + * 8. Custom preset shows base_url + auth_scheme fields; groq hides them. + * + * WHY vi.mock("../api"): wrappers call window.__TAURI__ IPC; mocking avoids + * wiring a real Tauri context in jsdom. + * + * WHY mock queries directly (not via queryClient): the component uses + * useQuery(transcriptionConfigQueryOptions()) which is an internal detail; + * mocking the hook via vi.mock provides stable fixtures across layout changes. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { okAsync, errAsync } from "neverthrow"; +import { TranscribeSettingsModal } from "../components/TranscribeSettingsModal"; +import * as api from "../api"; +import { renderWithProviders, resetUiStore } from "./test-utils"; +import type { CoreError, ListProvidersPayload, TranscriptionConfig } from "../bindings"; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { + ...actual, + setProviderKey: vi.fn(), + updateTranscriptionConfig: vi.fn(), + listProviders: vi.fn(), + getTranscriptionConfig: vi.fn(), + }; +}); + +const mockSetProviderKey = vi.mocked(api.setProviderKey); +const mockUpdateTranscriptionConfig = vi.mocked(api.updateTranscriptionConfig); +const mockListProviders = vi.mocked(api.listProviders); +const mockGetTranscriptionConfig = vi.mocked(api.getTranscriptionConfig); + +// ── Default fixtures ─────────────────────────────────────────────────────────── + +const emptyConfig: TranscriptionConfig = { + active_provider: null, + providers: {}, +}; + +const emptyProviders: ListProvidersPayload = { + active: null, + providers: [], +}; + +function setupDefaultMocks() { + mockGetTranscriptionConfig.mockReturnValue(okAsync(emptyConfig)); + mockListProviders.mockReturnValue(okAsync(emptyProviders)); + mockSetProviderKey.mockReturnValue(okAsync(undefined)); + mockUpdateTranscriptionConfig.mockReturnValue(okAsync(undefined)); +} + +// WHY: Save is gated on (configQuery.isSuccess && form.name.trim() !== "") +// to prevent two real data-integrity hazards (T10 review fix). Tests must +// wait for the config query to settle (and the name field to be filled) +// before clicking Save, otherwise the click is a no-op against a disabled +// button. Centralised here so adding a new Save-clicking test is one line. +async function waitForSaveEnabled(): Promise { + const saveBtn = screen.getByRole("button", { name: /save/i }); + await waitFor(() => { + expect(saveBtn).not.toBeDisabled(); + }); + return saveBtn; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + resetUiStore(); + setupDefaultMocks(); +}); + +describe("TranscribeSettingsModal", () => { + // 1. Renders form when open: true + it("renders form when open: true — shows provider input, key field, Save and Cancel", () => { + renderWithProviders( + , + ); + + // Modal container is present + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + + // Provider name input + expect(screen.getByLabelText(/provider name/i)).toBeInTheDocument(); + + // Preset dropdown + expect(screen.getByLabelText(/preset/i)).toBeInTheDocument(); + + // API key input (write-only password field) + expect(screen.getByLabelText(/api key/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/api key/i)).toHaveAttribute("type", "password"); + + // Save and Cancel buttons + expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + // 2. Renders nothing when open: false + it("renders nothing (modal hidden) when open: false", () => { + renderWithProviders( + , + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole("button", { name: /save/i })).toBeNull(); + }); + + // 3. Save flow happy path + it("happy path: fill name + key, click Save → both API calls fired → onClose + query invalidated", async () => { + const onClose = vi.fn(); + const { queryClient } = renderWithProviders( + , + ); + + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + // Fill provider name + const nameInput = screen.getByLabelText(/provider name/i); + fireEvent.change(nameInput, { target: { value: "groq" } }); + + // Fill API key + const keyInput = screen.getByLabelText(/api key/i); + fireEvent.change(keyInput, { target: { value: "gsk_test_key_abc" } }); + + // Click Save (after config query settles + name validates) + const saveBtn = await waitForSaveEnabled(); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(mockSetProviderKey).toHaveBeenCalledOnce(); + }); + + expect(mockSetProviderKey).toHaveBeenCalledWith("groq", "gsk_test_key_abc"); + + await waitFor(() => { + expect(mockUpdateTranscriptionConfig).toHaveBeenCalledOnce(); + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledOnce(); + }); + + // providers list query was invalidated — verify via toHaveBeenCalledWith + // using a cast to the concrete filter shape to satisfy the type-checked lint. + expect(invalidateSpy).toHaveBeenCalledOnce(); + const [firstArg] = invalidateSpy.mock.calls[0] ?? [undefined]; + expect(firstArg).toBeDefined(); + const filter = firstArg as { queryKey: readonly string[] }; + expect(filter.queryKey).toContain("providers"); + }); + + // 4. Save with empty API key skips setProviderKey + it("empty API key on edit: only updateTranscriptionConfig called — key not re-saved", async () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + // Fill only provider name, leave API key empty + const nameInput = screen.getByLabelText(/provider name/i); + fireEvent.change(nameInput, { target: { value: "openai" } }); + + // Key field explicitly left empty (default is "") + const keyInput = screen.getByLabelText(/api key/i); + expect((keyInput as HTMLInputElement).value).toBe(""); + + const saveBtn = await waitForSaveEnabled(); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(mockUpdateTranscriptionConfig).toHaveBeenCalledOnce(); + }); + + // setProviderKey must NOT be called when key is empty + expect(mockSetProviderKey).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledOnce(); + }); + }); + + // 5. Mid-save disable: Cancel disabled + Save shows spinner + it("mid-save: Cancel button disabled and Save shows 'Saving…' while mutation is pending", async () => { + // Make setProviderKey hang — never resolves so mutation stays pending. + // WHY ResultAsync.fromPromise with a never-resolving promise: okAsync() + // resolves immediately; we need a truly pending async value to exercise + // isPending === true in the React tree. + // WHY undefined (not void): TypeScript's no-invalid-void-type lint rule + // forbids void as a parameter type or generic arg; undefined is equivalent + // for a Promise that resolves with no meaningful value. + let resolveKey!: (value: undefined) => void; + const hangingPromise = new Promise((res) => { resolveKey = res; }); + const { ResultAsync } = await import("neverthrow"); + mockSetProviderKey.mockReturnValue( + ResultAsync.fromPromise(hangingPromise, () => ({ kind: "Internal", data: "hang" } as import("../bindings").CoreError)), + ); + + renderWithProviders( + , + ); + + const nameInput = screen.getByLabelText(/provider name/i); + fireEvent.change(nameInput, { target: { value: "groq" } }); + const keyInput = screen.getByLabelText(/api key/i); + fireEvent.change(keyInput, { target: { value: "gsk_pending_key" } }); + + const saveBtnReady = await waitForSaveEnabled(); + fireEvent.click(saveBtnReady); + + // After click, the mutation is pending — check disabled state + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); + }); + + // Save button text changes to indicate pending state + await waitFor(() => { + const saveBtn = screen.getByRole("button", { name: /saving/i }); + expect(saveBtn).toBeInTheDocument(); + }); + + // Unblock to clean up (avoid dangling async after test) + resolveKey(); + }); + + // 6. Error inline banner when setProviderKey rejects with Auth + it("setProviderKey Auth error → inline banner with authentication failed message", async () => { + const authError: CoreError = { + kind: "Transcription", + data: { kind: "Auth" }, + }; + mockSetProviderKey.mockReturnValue(errAsync(authError)); + + renderWithProviders( + , + ); + + const nameInput = screen.getByLabelText(/provider name/i); + fireEvent.change(nameInput, { target: { value: "groq" } }); + const keyInput = screen.getByLabelText(/api key/i); + fireEvent.change(keyInput, { target: { value: "bad_key" } }); + + const saveBtn = await waitForSaveEnabled(); + fireEvent.click(saveBtn); + + // Banner appears with auth message from coreErrorMessage + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + const alert = screen.getByRole("alert"); + expect(alert.textContent).toMatch(/authentication failed/i); + + // onClose must NOT have been called on error + // (no way to assert here since we didn't pass a spy, but confirm modal still open) + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + // 7. ESC key during save is no-op + it("ESC key during pending save does not call onClose", async () => { + // Make the mutation pending indefinitely using a never-resolving promise. + let resolveKey2!: (value: undefined) => void; + const hangingPromise2 = new Promise((res) => { resolveKey2 = res; }); + const { ResultAsync } = await import("neverthrow"); + mockSetProviderKey.mockReturnValue( + ResultAsync.fromPromise(hangingPromise2, () => ({ kind: "Internal", data: "hang" } as import("../bindings").CoreError)), + ); + + const onClose = vi.fn(); + renderWithProviders( + , + ); + + const nameInput = screen.getByLabelText(/provider name/i); + fireEvent.change(nameInput, { target: { value: "groq" } }); + const keyInput = screen.getByLabelText(/api key/i); + fireEvent.change(keyInput, { target: { value: "gsk_key" } }); + + const saveBtn = await waitForSaveEnabled(); + fireEvent.click(saveBtn); + + // Wait for mutation to be in-flight + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); + }); + + // Fire ESC on the dialog + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape", code: "Escape" }); + + // onClose must not have been called + expect(onClose).not.toHaveBeenCalled(); + + // Unblock + resolveKey2(); + }); + + // 8. Custom preset shows base_url + auth_scheme; groq hides them + it("custom preset shows base_url and auth_scheme fields; groq preset hides them", () => { + renderWithProviders( + , + ); + + const presetSelect = screen.getByLabelText(/preset/i); + + // Set preset to "custom" → extra fields appear + act(() => { + fireEvent.change(presetSelect, { target: { value: "custom" } }); + }); + + expect(screen.getByLabelText(/base url/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/auth scheme/i)).toBeInTheDocument(); + + // Set preset to "groq" → extra fields disappear + act(() => { + fireEvent.change(presetSelect, { target: { value: "groq" } }); + }); + + expect(screen.queryByLabelText(/base url/i)).toBeNull(); + expect(screen.queryByLabelText(/auth scheme/i)).toBeNull(); + }); + + // 9. Save disabled when name is empty (data-integrity: prevents + // providers[""] corrupt config row). + it("Save is disabled when provider name is empty", async () => { + renderWithProviders( + , + ); + + // Wait for config query to settle so the gate is purely on name. + await waitFor(() => { + expect(mockGetTranscriptionConfig).toHaveBeenCalled(); + }); + + // Default name is empty (INITIAL_FORM.name === ""). + const nameInput = screen.getByLabelText(/provider name/i); + expect(nameInput.value).toBe(""); + + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + + // Whitespace-only name still disabled (trim guard). + fireEvent.change(nameInput, { target: { value: " " } }); + expect(screen.getByRole("button", { name: /save/i })).toBeDisabled(); + + // Real name enables Save. + fireEvent.change(nameInput, { target: { value: "groq" } }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /save/i })).not.toBeDisabled(); + }); + }); +}); diff --git a/apps/desktop/src/__tests__/TranscriptionPill.test.tsx b/apps/desktop/src/__tests__/TranscriptionPill.test.tsx new file mode 100644 index 0000000..39fbf2a --- /dev/null +++ b/apps/desktop/src/__tests__/TranscriptionPill.test.tsx @@ -0,0 +1,265 @@ +/** + * TranscriptionPill — StatusBar widget showing in-flight transcription jobs. + * + * 10 scenarios: + * 1. Renders nothing when zero jobs in slice. + * 2. Renders pill with count when jobs present. + * 3. Click opens popover listing file names + statuses. + * 4. Popover lists all status types (queued/running/completed/cancelled/failed). + * 5. Cancel button calls api.cancelTranscription for queued/running jobs. + * 6. Cancel button absent on terminal jobs (completed/cancelled/failed). + * 7. Failed job tooltip shows error message from coreErrorMessage. + * 8. Popover closes on ESC keypress. + * 9. Jobs sorted newest first (descending started_at_ms). + * 10. Popover hides when jobs empty mid-render. + * + * WHY vi.mock("../api"): avoids Tauri IPC in jsdom. + * WHY seedJob via act(): renderWithProviders resets the store; seeding after + * render with act() lets the component re-render synchronously. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, fireEvent, screen } from "@testing-library/react"; +import { okAsync } from "neverthrow"; +import { TranscriptionPill } from "../components/TranscriptionPill"; +import * as api from "../api"; +import { useUiStore } from "../stores/ui"; +import { renderWithProviders, resetUiStore } from "./test-utils"; +import type { TranscriptionJob } from "../stores/ui"; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock("../api", async () => { + const actual = await vi.importActual("../api"); + return { + ...actual, + cancelTranscription: vi.fn(), + }; +}); + +const mockCancelTranscription = vi.mocked(api.cancelTranscription); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── + +function makeJob(overrides: Partial = {}): TranscriptionJob { + return { + request_uuid: "req-0001", + file_uuid: "file-0001", + file_name: "vacation.mp4", + status: { kind: "queued", queue_position: 1 }, + started_at_ms: 1_700_000_000_000, + ...overrides, + }; +} + +function seedJob(job: TranscriptionJob) { + useUiStore.getState().transcription.startJob(job); +} + +// ── Setup ────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + resetUiStore(); + mockCancelTranscription.mockReturnValue(okAsync(undefined)); +}); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("TranscriptionPill", () => { + // 1. Hidden when zero jobs + it("renders nothing when the jobs slice is empty", () => { + const { container } = renderWithProviders(); + // Component returns null — no DOM output. + expect(container).toBeEmptyDOMElement(); + }); + + // 2. Pill with count + it("renders pill showing 'Transcribing (N)' when N jobs are present", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ request_uuid: "req-A", file_name: "a.mp4" })); + seedJob(makeJob({ request_uuid: "req-B", file_name: "b.mp4" })); + }); + + const pill = screen.getByRole("button", { name: /transcribing/i }); + expect(pill.textContent).toMatch(/Transcribing \(2\)/i); + }); + + // 3. Click opens popover with file names + statuses + it("click opens popover listing job file names", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ request_uuid: "req-A", file_name: "alpha.mp4" })); + seedJob(makeJob({ request_uuid: "req-B", file_name: "beta.mp3" })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + + expect(screen.getByText("alpha.mp4")).toBeInTheDocument(); + expect(screen.getByText("beta.mp3")).toBeInTheDocument(); + }); + + // 4. All status types visible in popover + it("popover renders a badge for each status kind", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ + request_uuid: "req-queued", + file_name: "q.mp4", + status: { kind: "queued", queue_position: 1 }, + })); + seedJob(makeJob({ + request_uuid: "req-running", + file_name: "r.mp4", + status: { kind: "running", processed_ms: 3200, total_ms: 10_000 }, + })); + seedJob(makeJob({ + request_uuid: "req-completed", + file_name: "c.mp4", + status: { kind: "completed", transcript_id: "tx-1", segment_count: 3, language: "en" }, + })); + seedJob(makeJob({ + request_uuid: "req-cancelled", + file_name: "x.mp4", + status: { kind: "cancelled" }, + })); + seedJob(makeJob({ + request_uuid: "req-failed", + file_name: "f.mp4", + status: { kind: "failed", error: { kind: "Auth" } }, + })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + + // Each status has a visible badge text in the popover. + expect(screen.getByText(/Queued/i)).toBeInTheDocument(); + expect(screen.getByText(/Transcribing 32%/i)).toBeInTheDocument(); + expect(screen.getByText(/Done!/i)).toBeInTheDocument(); + expect(screen.getByText(/Cancelled/i)).toBeInTheDocument(); + expect(screen.getByText(/Failed/i)).toBeInTheDocument(); + }); + + // 5. Cancel button calls api.cancelTranscription for queued + running jobs + it("Cancel button fires api.cancelTranscription with the job's request_uuid", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ + request_uuid: "req-cancel-me", + file_name: "target.mp4", + status: { kind: "queued", queue_position: 2 }, + })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + expect(mockCancelTranscription).toHaveBeenCalledOnce(); + expect(mockCancelTranscription).toHaveBeenCalledWith("req-cancel-me"); + }); + + // 6. Cancel absent on terminal jobs + it("Cancel button is absent for completed, cancelled, and failed jobs", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ + request_uuid: "req-c", + file_name: "c.mp4", + status: { kind: "completed", transcript_id: "tx-1", segment_count: 1, language: null }, + })); + seedJob(makeJob({ + request_uuid: "req-x", + file_name: "x.mp4", + status: { kind: "cancelled" }, + })); + seedJob(makeJob({ + request_uuid: "req-f", + file_name: "f.mp4", + status: { kind: "failed", error: { kind: "Network", data: "ECONNRESET" } }, + })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + + // No Cancel button should appear for any of the three terminal states. + expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull(); + }); + + // 7. Failed job tooltip shows coreErrorMessage output + it("Failed job row carries a title with the coreErrorMessage string", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ + request_uuid: "req-fail", + file_name: "broken.mp4", + status: { kind: "failed", error: { kind: "Auth" } }, + })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + + // coreErrorMessage({ kind: "Transcription", data: { kind: "Auth" } }) + // → "Authentication failed — provider rejected the API key..." + const failedBadge = screen.getByTitle(/Authentication failed/i); + expect(failedBadge).toBeInTheDocument(); + }); + + // 8. Popover closes on ESC + it("popover closes when ESC is pressed", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ request_uuid: "req-esc", file_name: "esc.mp4" })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + expect(screen.getByText("esc.mp4")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + + expect(screen.queryByText("esc.mp4")).toBeNull(); + }); + + // 9. Sorted newest first (descending started_at_ms) + it("popover lists jobs newest-first by started_at_ms", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ + request_uuid: "req-old", + file_name: "older.mp4", + started_at_ms: 1_000_000_000_000, + })); + seedJob(makeJob({ + request_uuid: "req-new", + file_name: "newer.mp4", + started_at_ms: 2_000_000_000_000, + })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + + const items = screen.getAllByTestId("transcription-job-row"); + // First row should be the newer job. + expect(items[0]).toHaveTextContent("newer.mp4"); + expect(items[1]).toHaveTextContent("older.mp4"); + }); + + // 10. Popover hides when jobs become empty mid-render + it("popover disappears when the jobs slice empties after open", () => { + renderWithProviders(); + act(() => { + seedJob(makeJob({ request_uuid: "req-gone", file_name: "gone.mp4" })); + }); + + fireEvent.click(screen.getByRole("button", { name: /transcribing/i })); + expect(screen.getByText("gone.mp4")).toBeInTheDocument(); + + // Clear the jobs slice — simulate auto-removal by useDomainEvents. + act(() => { + useUiStore.getState().transcription.removeJob("req-gone"); + }); + + // Component returns null when jobs is empty; popover (and pill) both gone. + expect(screen.queryByText("gone.mp4")).toBeNull(); + expect(screen.queryByRole("button", { name: /transcribing/i })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/__tests__/hooks/useDomainEvents.test.tsx b/apps/desktop/src/__tests__/hooks/useDomainEvents.test.tsx index 7922ca8..631e28c 100644 --- a/apps/desktop/src/__tests__/hooks/useDomainEvents.test.tsx +++ b/apps/desktop/src/__tests__/hooks/useDomainEvents.test.tsx @@ -19,6 +19,7 @@ import { filesKeys } from "../../queries/files"; import { tagsKeys } from "../../queries/tags"; import { searchKeys } from "../../queries/search"; import { dedupKeys } from "../../queries/dedup"; +import { transcriptsKeys } from "../../queries/transcripts"; import { useUiStore } from "../../stores/ui"; import { makeFreshQueryClient, resetUiStore } from "../test-utils"; @@ -328,4 +329,278 @@ describe("useDomainEvents", () => { expect(calledKeys).not.toContainEqual(tagsKeys.all); expect(calledKeys).not.toContainEqual(searchKeys.all); }); + + // ── T8: 5 new transcription arms ────────────────────────────────── + + test("TranscriptionStarted seeds the slice as 'running' and invalidates per-file transcripts", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + invalidateSpy.mockClear(); + + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-001", + file_uuid: "file-aaa", + file_name: "vid.mp4", + queue_size: 1, + }, + }); + }); + + const job = useUiStore.getState().transcription.jobs["req-001"]; + expect(job).toBeDefined(); + expect(job?.file_uuid).toBe("file-aaa"); + expect(job?.file_name).toBe("vid.mp4"); + expect(job?.status.kind).toBe("running"); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: transcriptsKeys.byFileUuid("file-aaa"), + }); + }); + + test("TranscriptionProgress mutates the running status with new processed_ms", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + + // Seed via Started. + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-002", + file_uuid: "file-bbb", + file_name: "clip.wav", + queue_size: 1, + }, + }); + }); + + act(() => { + sub.fire({ + kind: "TranscriptionProgress", + data: { request_uuid: "req-002", processed_ms: 4500, total_ms: 12_000 }, + }); + }); + + const status = useUiStore.getState().transcription.jobs["req-002"]?.status; + expect(status).toEqual({ kind: "running", processed_ms: 4500, total_ms: 12_000 }); + }); + + test("TranscriptionCompleted updates status, invalidates queries, and auto-removes after 5s", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-003", + file_uuid: "file-ccc", + file_name: "song.mp3", + queue_size: 1, + }, + }); + }); + invalidateSpy.mockClear(); + + act(() => { + sub.fire({ + kind: "TranscriptionCompleted", + data: { + request_uuid: "req-003", + transcript_id: "tx-001", + file_uuid: "file-ccc", + segment_count: 12, + language: "en", + }, + }); + }); + + const status = useUiStore.getState().transcription.jobs["req-003"]?.status; + expect(status).toEqual({ + kind: "completed", + transcript_id: "tx-001", + segment_count: 12, + language: "en", + }); + + // Both invalidations must fire on completion. + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: transcriptsKeys.byFileUuid("file-ccc"), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: searchKeys.all }); + + // Job is still present right before the 5s grace window expires. + await act(async () => { await vi.advanceTimersByTimeAsync(4_999); }); + expect(useUiStore.getState().transcription.jobs["req-003"]).toBeDefined(); + + // After 5s, job auto-removes. + await act(async () => { await vi.advanceTimersByTimeAsync(2); }); + expect(useUiStore.getState().transcription.jobs["req-003"]).toBeUndefined(); + }); + + test("TranscriptionCancelled marks slice as cancelled and auto-removes after 3s", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-004", + file_uuid: "file-ddd", + file_name: "talk.m4a", + queue_size: 1, + }, + }); + }); + + act(() => { + sub.fire({ + kind: "TranscriptionCancelled", + data: { request_uuid: "req-004" }, + }); + }); + + expect(useUiStore.getState().transcription.jobs["req-004"]?.status).toEqual({ + kind: "cancelled", + }); + + await act(async () => { await vi.advanceTimersByTimeAsync(2_999); }); + expect(useUiStore.getState().transcription.jobs["req-004"]).toBeDefined(); + + await act(async () => { await vi.advanceTimersByTimeAsync(2); }); + expect(useUiStore.getState().transcription.jobs["req-004"]).toBeUndefined(); + }); + + test("TranscriptionFailed marks slice as failed, notifies, and does NOT auto-remove", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-005", + file_uuid: "file-eee", + file_name: "broken.mov", + queue_size: 1, + }, + }); + }); + + act(() => { + sub.fire({ + kind: "TranscriptionFailed", + data: { + request_uuid: "req-005", + error: { kind: "Auth" }, + }, + }); + }); + + const status = useUiStore.getState().transcription.jobs["req-005"]?.status; + expect(status).toEqual({ kind: "failed", error: { kind: "Auth" } }); + + // notifyError fired (one toast in the slice now). + const notes = useUiStore.getState().notifications; + expect(notes).toHaveLength(1); + expect(notes[0]?.kind).toBe("error"); + expect(notes[0]?.message).toMatch(/\[Transcription\]/); + + // Failed jobs persist past every other auto-remove window — let's check + // 10s in (well beyond the 5s completed grace) and confirm the job is still there. + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + expect(useUiStore.getState().transcription.jobs["req-005"]).toBeDefined(); + }); + + test("Unmount clears pending auto-remove timers (no slice mutation post-unmount)", async () => { + vi.useFakeTimers(); + const sub = createSubscription(); + const queryClient = makeFreshQueryClient(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { unmount } = renderHook(() => { useDomainEvents(); }, { wrapper }); + await vi.runAllTimersAsync(); + + act(() => { + sub.fire({ + kind: "TranscriptionStarted", + data: { + request_uuid: "req-006", + file_uuid: "file-fff", + file_name: "x.mp4", + queue_size: 1, + }, + }); + }); + + act(() => { + sub.fire({ + kind: "TranscriptionCompleted", + data: { + request_uuid: "req-006", + transcript_id: "tx-006", + file_uuid: "file-fff", + segment_count: 1, + language: null, + }, + }); + }); + + // Job is in the slice; the 5s grace timer is pending. + expect(useUiStore.getState().transcription.jobs["req-006"]).toBeDefined(); + + // Unmount should clear the timer; advancing past 5s must not mutate the slice. + unmount(); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + expect(useUiStore.getState().transcription.jobs["req-006"]).toBeDefined(); + }); }); diff --git a/apps/desktop/src/__tests__/test-utils.tsx b/apps/desktop/src/__tests__/test-utils.tsx index 6e5aac5..d82b089 100644 --- a/apps/desktop/src/__tests__/test-utils.tsx +++ b/apps/desktop/src/__tests__/test-utils.tsx @@ -70,9 +70,21 @@ export const defaultUiState = { selectedFileUuid: null, }; -/** Reset the UI store to {@link defaultUiState}. Call from `beforeEach`. */ +/** + * Reset the UI store to {@link defaultUiState}. Call from `beforeEach`. + * + * WHY merge with the existing transcription slice (T8): `setState` shallow- + * merges; we restore the leaf primitives but keep the live `transcription` + * sub-object (with its action closures) intact, then zero its `jobs` map. + * Re-spreading actions into `defaultUiState` would entangle the two layers + * and force every test author to remember the slice's shape. + */ export function resetUiStore(): void { useUiStore.setState(defaultUiState); + // Reset the in-flight transcription jobs map without re-binding the action + // closures. This keeps existing slice methods callable across tests. + const { transcription } = useUiStore.getState(); + useUiStore.setState({ transcription: { ...transcription, jobs: {} } }); } /** Build a QueryClient with test-friendly defaults (no gc, no retry). */ diff --git a/apps/desktop/src/__tests__/transcription-slice.test.ts b/apps/desktop/src/__tests__/transcription-slice.test.ts new file mode 100644 index 0000000..18f7560 --- /dev/null +++ b/apps/desktop/src/__tests__/transcription-slice.test.ts @@ -0,0 +1,160 @@ +/** + * TranscriptionSlice — unit tests for the in-flight job map. + * + * The slice tracks request_uuid → TranscriptionJob; `useDomainEvents` mutates + * it from `AppEvent::Transcription*` arms. Tests pin the contract: + * + * 1. `startJob` adds to the map keyed by `request_uuid`. + * 2. `updateJob` mutates the status field; missing keys are a no-op. + * 3. `removeJob` deletes by `request_uuid`. + * 4. Multiple jobs coexist independently (different request_uuids). + * + * WHY no-op on missing-uuid update: the auto-remove timer in `useDomainEvents` + * (5s for Completed, 3s for Cancelled) can race with a user-initiated + * `removeJob`; making `updateJob` defensive removes a class of timer-vs-user + * races without forcing every call site to look up first. + */ +import { describe, expect, test, beforeEach } from "vitest"; +import { useUiStore } from "../stores/ui"; +import { resetUiStore } from "./test-utils"; +import type { TranscriptionJob, TranscriptionJobStatus } from "../stores/ui"; + +beforeEach(() => { + resetUiStore(); +}); + +function makeJob(overrides: Partial = {}): TranscriptionJob { + return { + request_uuid: "req-1", + file_uuid: "file-1", + file_name: "vacation.mp4", + status: { kind: "queued", queue_position: 1 }, + started_at_ms: 1_700_000_000_000, + ...overrides, + }; +} + +describe("TranscriptionSlice", () => { + test("initial jobs map is empty", () => { + expect(useUiStore.getState().transcription.jobs).toEqual({}); + }); + + test("startJob adds the job to the map keyed by request_uuid", () => { + const job = makeJob({ request_uuid: "req-aaa" }); + useUiStore.getState().transcription.startJob(job); + expect(useUiStore.getState().transcription.jobs).toEqual({ "req-aaa": job }); + }); + + test("updateJob mutates the status field on an existing job", () => { + const job = makeJob({ request_uuid: "req-bbb" }); + useUiStore.getState().transcription.startJob(job); + + const next: TranscriptionJobStatus = { + kind: "running", + processed_ms: 500, + total_ms: 12_000, + }; + useUiStore.getState().transcription.updateJob("req-bbb", next); + + const updated = useUiStore.getState().transcription.jobs["req-bbb"]; + expect(updated).toBeDefined(); + expect(updated?.status).toEqual(next); + // Other fields untouched. + expect(updated?.file_uuid).toBe("file-1"); + expect(updated?.file_name).toBe("vacation.mp4"); + expect(updated?.started_at_ms).toBe(job.started_at_ms); + }); + + test("updateJob walks queued -> running -> completed", () => { + const job = makeJob({ request_uuid: "req-ccc" }); + useUiStore.getState().transcription.startJob(job); + + useUiStore.getState().transcription.updateJob("req-ccc", { + kind: "running", + processed_ms: 0, + total_ms: null, + }); + expect(useUiStore.getState().transcription.jobs["req-ccc"]?.status.kind).toBe( + "running", + ); + + useUiStore.getState().transcription.updateJob("req-ccc", { + kind: "completed", + transcript_id: "tx-001", + segment_count: 7, + language: "en", + }); + expect(useUiStore.getState().transcription.jobs["req-ccc"]?.status).toEqual({ + kind: "completed", + transcript_id: "tx-001", + segment_count: 7, + language: "en", + }); + }); + + test("removeJob deletes the entry from the map", () => { + const job = makeJob({ request_uuid: "req-ddd" }); + useUiStore.getState().transcription.startJob(job); + expect(useUiStore.getState().transcription.jobs["req-ddd"]).toBeDefined(); + + useUiStore.getState().transcription.removeJob("req-ddd"); + expect(useUiStore.getState().transcription.jobs["req-ddd"]).toBeUndefined(); + expect(useUiStore.getState().transcription.jobs).toEqual({}); + }); + + test("multiple jobs coexist with different request_uuids", () => { + const a = makeJob({ request_uuid: "req-A", file_uuid: "file-A" }); + const b = makeJob({ request_uuid: "req-B", file_uuid: "file-B" }); + const c = makeJob({ request_uuid: "req-C", file_uuid: "file-C" }); + + useUiStore.getState().transcription.startJob(a); + useUiStore.getState().transcription.startJob(b); + useUiStore.getState().transcription.startJob(c); + + const jobs = useUiStore.getState().transcription.jobs; + expect(Object.keys(jobs).sort()).toEqual(["req-A", "req-B", "req-C"]); + expect(jobs["req-A"]?.file_uuid).toBe("file-A"); + expect(jobs["req-B"]?.file_uuid).toBe("file-B"); + expect(jobs["req-C"]?.file_uuid).toBe("file-C"); + + // Removing one leaves the others intact. + useUiStore.getState().transcription.removeJob("req-B"); + const after = useUiStore.getState().transcription.jobs; + expect(Object.keys(after).sort()).toEqual(["req-A", "req-C"]); + }); + + test("updateJob on missing request_uuid is a no-op (does NOT throw)", () => { + expect(() => { + useUiStore.getState().transcription.updateJob("req-missing", { + kind: "running", + processed_ms: 0, + total_ms: null, + }); + }).not.toThrow(); + expect(useUiStore.getState().transcription.jobs).toEqual({}); + }); + + test("removeJob on missing request_uuid is a no-op (does NOT throw)", () => { + expect(() => { + useUiStore.getState().transcription.removeJob("req-missing"); + }).not.toThrow(); + expect(useUiStore.getState().transcription.jobs).toEqual({}); + }); + + test("status discriminants serialise per the discriminated-union contract", () => { + const failed: TranscriptionJobStatus = { + kind: "failed", + error: { kind: "Auth" }, + }; + const cancelled: TranscriptionJobStatus = { kind: "cancelled" }; + const job1 = makeJob({ request_uuid: "req-x", status: failed }); + const job2 = makeJob({ request_uuid: "req-y", status: cancelled }); + + useUiStore.getState().transcription.startJob(job1); + useUiStore.getState().transcription.startJob(job2); + + const got = useUiStore.getState().transcription.jobs; + expect(got["req-x"]?.status).toEqual(failed); + expect(got["req-y"]?.status).toEqual(cancelled); + }); +}); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 601221d..dcc5f32 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -22,9 +22,12 @@ import type { FileUuid, FileWithMetadataPayload, FileWithTagsPayload, + ListProvidersPayload, ScanReport, SearchHit, Tag, + TranscribeStartedPayload, + TranscriptionConfig, VolumeRecord, } from "./bindings"; @@ -46,6 +49,8 @@ const KNOWN_KINDS: ReadonlySet = new Set([ "Internal", "FullHashUnavailable", "BackupFailed", + // T8 (transcription-v1): wraps a `TranscriptionError` in `data`. + "Transcription", ]); /** @@ -357,3 +362,107 @@ export function backupDatabase( force, }); } + +// ── Transcription wrappers (T8) ────────────────────────────────────── +// +// Eight thin wrappers around the T7 Tauri commands. Tauri v2 auto-snake- +// cases camelCase keys on the Rust side; we still pass snake_case explicitly +// here to keep the wire shape obvious in `git grep` (matches the dedup + +// backup wrappers above). + +/** + * Enqueue a transcription job. Returns the per-request UUIDv7 + queue + * position immediately — actual transcription progress arrives via the + * `app-event` channel as `AppEvent::Transcription{Started,Progress,...}`. + * + * @param args - Job inputs: + * - `fileUuid`: stable file surrogate (FileUuid). + * - `fileName`: display name for UI surfacing. + * - `source`: absolute path to the media file on disk. + * - `languageHint`: optional BCP-47 short code; null = autodetect. + */ +export function transcribe(args: { + fileUuid: string; + fileName: string; + source: string; + languageHint: string | null; +}): ResultAsync { + return fromInvoke("transcribe", { + file_uuid: args.fileUuid, + file_name: args.fileName, + source: args.source, + language_hint: args.languageHint, + }); +} + +/** + * Cancel an in-flight or queued transcription job by `request_uuid`. + * Idempotent — cancelling an unknown id is a no-op (returns Ok per T7). + */ +export function cancelTranscription( + requestUuid: string, +): ResultAsync { + return fromInvoke("cancel_transcription", { request_uuid: requestUuid }); +} + +/** + * Store an API key under `provider` in the OS keyring (service + * `"perima.transcription"`). Overwrites any existing key for the provider. + * + * WHY no `password` parameter: we never echo the key back to the frontend + * after save; the caller is the Settings modal's write-only field. + */ +export function setProviderKey( + provider: string, + apiKey: string, +): ResultAsync { + return fromInvoke("set_provider_key", { provider, api_key: apiKey }); +} + +/** + * Remove the keyring entry for `provider`. Idempotent — returns Ok when + * no entry exists. + */ +export function deleteProviderKey( + provider: string, +): ResultAsync { + return fromInvoke("delete_provider_key", { provider }); +} + +/** + * Probe whether a keyring entry exists for `provider`. Drives the + * `••••••••` placeholder vs empty input field in the Settings modal. + */ +export function hasProviderKey( + provider: string, +): ResultAsync { + return fromInvoke("has_provider_key", { provider }); +} + +/** + * List every configured transcription provider with its preset, optional + * model override, and a `has_key` flag. The returned `active` field echoes + * `TranscriptionConfig.active_provider`. + */ +export function listProviders(): ResultAsync { + return fromInvoke("list_providers", {}); +} + +/** + * Persist the supplied `TranscriptionConfig` to disk. Hot-reload is NOT + * implemented in T7 — the user must restart perima for the new active + * provider / model to take effect. The frontend should surface a banner. + */ +export function updateTranscriptionConfig( + config: TranscriptionConfig, +): ResultAsync { + return fromInvoke("update_transcription_config", { config }); +} + +/** + * Read the current `TranscriptionConfig` from disk. Returns an empty + * default (no providers, no active) when the config file is missing. + */ +export function getTranscriptionConfig(): ResultAsync { + return fromInvoke("get_transcription_config", {}); +} diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts index f32459d..4bceb18 100644 --- a/apps/desktop/src/bindings.ts +++ b/apps/desktop/src/bindings.ts @@ -87,7 +87,45 @@ export type AppEvent = latest_outcome: FullHashOutcome; }; } - | { kind: "VerifyComplete"; data: { batch_id: BatchId } }; + | { kind: "VerifyComplete"; data: { batch_id: BatchId } } + // T8 (transcription-v1): 5 channel-only variants emitted by the + // transcription worker. Hand-crafted (channel events bypass tauri-specta); + // shapes mirror crates/core/src/events.rs::AppEvent::Transcription*. + | { + kind: "TranscriptionStarted"; + data: { + request_uuid: string; + file_uuid: string; + file_name: string; + queue_size: number; + }; + } + | { + kind: "TranscriptionProgress"; + data: { + request_uuid: string; + processed_ms: number; + total_ms: number | null; + }; + } + | { + kind: "TranscriptionCompleted"; + data: { + request_uuid: string; + transcript_id: string; + file_uuid: string; + segment_count: number; + language: string | null; + }; + } + | { kind: "TranscriptionCancelled"; data: { request_uuid: string } } + | { + kind: "TranscriptionFailed"; + data: { + request_uuid: string; + error: TranscriptionError; + }; + }; /** * Categorical reason an index was invalidated. Inner field of @@ -149,7 +187,38 @@ export type CoreError = | { kind: "Unsupported"; data: string } | { kind: "Internal"; data: string } | { kind: "FullHashUnavailable"; data: { reason: FullHashUnavailableReason } } - | { kind: "BackupFailed"; data: { reason: BackupFailureReason } }; + | { kind: "BackupFailed"; data: { reason: BackupFailureReason } } + /** + * Transcription failure (cloud or local STT backend). Inner payload is the + * full `TranscriptionError` with its own `kind` discriminant. + * Rust: `CoreError::Transcription(#[from] TranscriptionError)`. + */ + | { kind: "Transcription"; data: TranscriptionError }; + +/** + * All transcription-adapter failure modes. + * + * Rust: `crates/core/src/transcription.rs::TranscriptionError` with + * `#[serde(tag = "kind", content = "data")]` and `#[non_exhaustive]`. + * + * WHY hand-crafted: this enum is a transitive field of + * `CoreError::Transcription` and `AppEvent::TranscriptionFailed`. Both surfaces + * cross the IPC boundary; `tauri-specta` walks command-return types but not + * channel-only types — this enum needs to be in the committed bindings until + * tauri-specta gains a registered-events emitter. + */ +export type TranscriptionError = + | { kind: "Network"; data: string } + | { kind: "Auth" } + | { kind: "RateLimited"; data: { retry_after_secs: number | null } } + | { kind: "QuotaExceeded" } + | { kind: "ModelNotFound"; data: { backend: string; model: string } } + | { kind: "AudioDecode"; data: string } + | { kind: "FileTooLarge"; data: { limit_bytes: number } } + | { kind: "Cancelled" } + | { kind: "BackendUnavailable"; data: { reason: string } } + | { kind: "QueueFull"; data: { queued: number } } + | { kind: "Internal"; data: string }; // ── Structs ────────────────────────────────────────────────────────── @@ -281,6 +350,18 @@ export type FileWithMetadataPayload = { mime_type: string | null; thumbnail_path: string | null; thumbnail_status: string | null; + /** + * Absolute on-disk path joining the volume's current mount root with + * `relative_path`. `null` when the volume is not currently mounted on + * this machine — UI controls that need an on-disk path (transcribe, + * open-file, preview) must be disabled in that case. + * + * T9 review fix: pre-fix the frontend passed `relative_path` to the + * backend `transcribe` command, which forwards untouched to ffmpeg. + * ffmpeg resolves relative paths against the desktop process cwd, + * producing silent ENOENT in any normal install. + */ + absolute_path: string | null; }; /** @@ -371,3 +452,74 @@ export type BackupOutput = { /** Size in bytes of the freshly written backup file. */ size_bytes: number; }; + +// ── Transcription types (T7) ───────────────────────────────────────── + +/** + * Returned by the `transcribe` Tauri command — wire-mirror of + * `perima_app::TranscribeOutput::Started`. + * Rust: `crates/desktop/src/payloads.rs::TranscribeStartedPayload`. + */ +export type TranscribeStartedPayload = { + /** Per-request UUIDv7 (lowercase-hex simple form). Pairs with every + * `AppEvent::Transcription*` variant for this job. */ + request_uuid: string; + /** 1-based position in the queue at the moment of enqueue. */ + queue_position: number; +}; + +/** + * One entry in `ListProvidersPayload.providers`. + * Rust: `crates/desktop/src/payloads.rs::ProviderListEntry`. + */ +export type ProviderListEntry = { + /** Provider name (the `[transcription.providers.]` key). */ + name: string; + /** Preset name (`groq`, `openai`, `custom`, ...). */ + preset: string; + /** Optional model override; null falls back to the preset's default. */ + model: string | null; + /** Whether a keyring entry exists for this provider on this device. */ + has_key: boolean; +}; + +/** + * Returned by the `list_providers` Tauri command. + * Rust: `crates/desktop/src/payloads.rs::ListProvidersPayload`. + */ +export type ListProvidersPayload = { + /** Active provider name from `[transcription].active_provider`, or null. */ + active: string | null; + /** One entry per provider in `[transcription.providers.*]`. */ + providers: ProviderListEntry[]; +}; + +/** + * One provider's TOML entry. + * Rust: `perima_app::config::transcription::ProviderEntry`. + */ +export type ProviderEntry = { + /** Preset name from `KNOWN_PROVIDERS` (`groq`, `openai`, `custom`). */ + preset: string; + /** Optional model override; falls back to the preset's default. */ + model: string | null; + /** Optional `base_url` override (required for `preset = "custom"`). */ + base_url: string | null; + /** Auth scheme override for custom providers. */ + auth_scheme: string | null; +}; + +/** + * Top-level transcription config (one TOML table). + * Rust: `perima_app::config::transcription::TranscriptionConfig`. + * + * Lives at `/config.toml` under the `[transcription]` table. + * Returned by `get_transcription_config`; accepted by + * `update_transcription_config`. + */ +export type TranscriptionConfig = { + /** Active provider name (must match one of `providers.*` keys). */ + active_provider: string | null; + /** Per-provider configuration table. Empty = no providers wired up yet. */ + providers: { [key: string]: ProviderEntry }; +}; diff --git a/apps/desktop/src/components/FileSidebar.tsx b/apps/desktop/src/components/FileSidebar.tsx index c0d8490..cd4c2e5 100644 --- a/apps/desktop/src/components/FileSidebar.tsx +++ b/apps/desktop/src/components/FileSidebar.tsx @@ -24,9 +24,12 @@ * a small UX win. We keep it to a one-line click handler; no third-party * clipboard lib needed. */ -import { XIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import { XIcon, GearIcon } from "@phosphor-icons/react"; import type { FileWithTagsPayload } from "../bindings"; import { useComputeFullHash } from "../queries/dedup"; +import { TranscribeButton } from "./TranscribeButton"; +import { TranscribeSettingsModal } from "./TranscribeSettingsModal"; /** Props for {@link FileSidebar}. */ export interface FileSidebarProps { @@ -44,6 +47,7 @@ export interface FileSidebarProps { */ export default function FileSidebar({ file, onClose }: FileSidebarProps) { const compute = useComputeFullHash(); + const [settingsOpen, setSettingsOpen] = useState(false); // WHY two conditions: see module docstring. // hash === null → post-V012 nullable convention (forward-compat). @@ -78,6 +82,30 @@ export default function FileSidebar({ file, onClose }: FileSidebarProps) { + {/* Transcription action row */} + {/* WHY adjacent to Settings gear: spec §9 places the transcribe trigger + beside a gear icon that will open the provider settings modal (T10). + The gear is a stub here — onClick is wired in T10. */} +
+ + + { setSettingsOpen(false); }} + /> +
+ {/* UUID */}

diff --git a/apps/desktop/src/components/StatusBar.tsx b/apps/desktop/src/components/StatusBar.tsx index 7e52d5e..2449f0b 100644 --- a/apps/desktop/src/components/StatusBar.tsx +++ b/apps/desktop/src/components/StatusBar.tsx @@ -21,6 +21,8 @@ import { useUiStore } from "../stores/ui"; import { useCollisions } from "../queries/dedup"; import { useBackupDatabase } from "../queries/backup"; import CollisionPill from "./CollisionPill"; +import { TranscriptionPill } from "./TranscriptionPill"; +import { coreErrorMessage } from "../lib/coreError"; import type { BackupFailureReason, CoreError, FullHashUnavailableReason } from "../bindings"; /** @@ -53,6 +55,11 @@ export function errorKindLabel(err: CoreError): string { return `Full hash unavailable: ${fullHashUnavailableReasonLabel(err.data.reason)}`; case "BackupFailed": return `Backup failed: ${backupFailureReasonLabel(err.data.reason)}`; + case "Transcription": + // WHY delegate to coreErrorMessage: the inner TranscriptionError variant + // switch lives in lib/coreError.ts (single source of truth). StatusBar + // only needs the user-facing string; per-variant rendering is a follow-up. + return `Transcription error: ${coreErrorMessage(err)}`; default: { // WHY never: TypeScript exhaustiveness check. If a new CoreError variant // is added to bindings.ts without a matching case above, this line @@ -141,6 +148,7 @@ export default function StatusBar() { {backupMutation.isPending ? "Backing up…" : "Backup"} + ); diff --git a/apps/desktop/src/components/TranscribeButton.tsx b/apps/desktop/src/components/TranscribeButton.tsx new file mode 100644 index 0000000..8119493 --- /dev/null +++ b/apps/desktop/src/components/TranscribeButton.tsx @@ -0,0 +1,194 @@ +/** + * TranscribeButton — per-file transcription action with 3 visual states: + * - Idle: "Transcribe" → fires api.transcribe. + * - Running (queued or in-progress): shows queue position or percent → click cancels. + * - Terminal (completed / cancelled / failed): brief disabled flash, then auto-removes + * (handled by T8's useDomainEvents grace period). Failed jobs persist until retry. + * + * WHY plain HTML buttons (not shadcn): no shadcn ui/ components exist in this project. + * WHY no useMemo / useCallback: React Compiler 1.0 is enabled (CLAUDE.md). + * WHY single-property selectors: Zustand 5 scalar reads are stable references without + * useShallow; multi-property reads use useShallow (not needed here — one selector per hook). + */ +import { useUiStore } from "../stores/ui"; +import { coreErrorMessage } from "../lib/coreError"; +import * as api from "../api"; + +/** Props for {@link TranscribeButton}. */ +export interface TranscribeButtonProps { + /** Stable file surrogate (FileUuid). Used to match in-flight jobs. */ + fileUuid: string; + /** Display name for the transcription job (shown in the TranscriptionPill). */ + fileName: string; + /** + * Absolute on-disk path passed to the backend for transcription, or `null` + * when the file's volume is not currently mounted. + * + * WHY `string | null` (T9 review fix): the backend cannot resolve relative + * paths — it forwards `source` unchanged to ffmpeg, which resolves against + * the desktop process cwd (rarely the volume root → silent ENOENT). + * `FileWithMetadataPayload` now exposes a precomputed `absolute_path` that + * joins the live `volume_mounts.mount_path` with `relative_path`. `null` + * means the volume is unmounted — the UI disables the button to prevent a + * guaranteed transcription failure. + */ + source: string | null; +} + +/** + * Per-file transcription control that reflects the live job slice from + * `useUiStore`. Renders a context-sensitive button based on `activeJob?.status.kind`. + */ +export function TranscribeButton({ fileUuid, fileName, source }: TranscribeButtonProps) { + const activeJob = useUiStore((s) => + Object.values(s.transcription.jobs).find((j) => j.file_uuid === fileUuid), + ); + const notifyError = useUiStore((s) => s.notifyError); + + function onClickStart() { + // WHY guard: the Idle render disables the button when source is null, + // but the Cancelled / Failed retry buttons share this handler and run + // through the same `(source, fileUuid, fileName)` closure. Guarding + // here keeps the contract honest — re-clicking after the volume + // unmounts mid-job no-ops instead of firing a guaranteed-ENOENT call. + if (source === null) return; + api + .transcribe({ fileUuid, fileName, source, languageHint: null }) + .mapErr((err) => { notifyError(err); }); + } + + function onClickCancel() { + if (!activeJob) return; + api + .cancelTranscription(activeJob.request_uuid) + .mapErr((err) => { notifyError(err); }); + } + + // ── Shared button class bases ──────────────────────────────────────────────── + const baseClass = + "inline-flex items-center justify-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium " + + "transition-colors duration-micro ease-perima " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " + + "focus-visible:ring-offset-2 focus-visible:ring-offset-background " + + "disabled:opacity-40 disabled:pointer-events-none"; + + const primaryClass = `${baseClass} bg-primary text-primary-foreground hover:bg-primary/90`; + const ghostClass = `${baseClass} bg-muted text-foreground hover:bg-muted/70`; + const destructiveClass = `${baseClass} bg-destructive text-destructive-foreground hover:bg-destructive/90`; + + // ── No active job → Idle ───────────────────────────────────────────────────── + if (!activeJob) { + // WHY null source = disabled with tooltip (T9 review fix): the file's + // volume is not currently mounted, so the backend has no absolute path + // to hand ffmpeg. Disabling here prevents firing a doomed IPC call. + if (source === null) { + return ( + + ); + } + return ( + + ); + } + + const { status } = activeJob; + + // ── Queued ─────────────────────────────────────────────────────────────────── + if (status.kind === "queued") { + return ( + + ); + } + + // ── Running ────────────────────────────────────────────────────────────────── + if (status.kind === "running") { + const percent = + status.total_ms !== null && status.total_ms > 0 + ? Math.round((status.processed_ms / status.total_ms) * 100) + : null; + const label = percent !== null ? `${String(percent)}%` : "..."; + return ( + + ); + } + + // ── Completed ──────────────────────────────────────────────────────────────── + // WHY disabled: the job auto-removes after 5s (useDomainEvents grace period), + // at which point the component re-renders as Idle. Disabling prevents double-clicks. + if (status.kind === "completed") { + return ( + + ); + } + + // ── Cancelled ──────────────────────────────────────────────────────────────── + // WHY same handler as Idle: clicking re-submits the job. The slice auto-removes + // the cancelled entry after 3s (useDomainEvents); the button then re-renders Idle. + if (status.kind === "cancelled") { + return ( + + ); + } + + // ── Failed ─────────────────────────────────────────────────────────────────── + // WHY tooltip with title: simple native tooltip for the error message; T10 can + // upgrade to a popover when the Settings modal lands. + // WHY data-variant: surfaced to tests that verify the destructive variant is applied + // (no shadcn attr; we carry it as a data- attribute so assertions are explicit). + const errorMessage = coreErrorMessage({ kind: "Transcription", data: status.error }); + return ( + + ); +} diff --git a/apps/desktop/src/components/TranscribeSettingsModal.tsx b/apps/desktop/src/components/TranscribeSettingsModal.tsx new file mode 100644 index 0000000..ade0188 --- /dev/null +++ b/apps/desktop/src/components/TranscribeSettingsModal.tsx @@ -0,0 +1,521 @@ +/** + * TranscribeSettingsModal — provider key + config editor. + * + * Opens when the user clicks the gear icon beside the TranscribeButton in + * FileSidebar. Supports adding/editing transcription providers (Groq, OpenAI, + * Custom). Changes are persisted via two sequential Tauri commands: + * 1. `set_provider_key` — writes the API key to the OS keyring (only when + * the user supplies a non-empty key; leaving it blank preserves the + * existing keyring entry). + * 2. `update_transcription_config` — writes provider preset / model / + * base_url / auth_scheme + optionally sets the provider as active. + * + * WHY plain-div modal (no shadcn Dialog): the project has no UI component + * library installed. We implement a minimal role="dialog" with an ESC handler + * and backdrop click guard instead of pulling in Radix (unvetted dep). + * + * WHY no useMemo / useCallback: React Compiler 1.0 is enabled (CLAUDE.md). + * + * WHY useState for open state: the parent controls open/close; this component + * holds only ephemeral form state (not global UI state → no Zustand needed). + */ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryOptions } from "@tanstack/react-query"; +import * as api from "../api"; +import { coreErrorMessage } from "../lib/coreError"; +import type { CoreError, ProviderEntry, TranscriptionConfig } from "../bindings"; + +// ── Query key + options for the provider list ──────────────────────────────── + +/** Stable query key used to list providers and drive cache invalidation. */ +const PROVIDERS_QUERY_KEY = ["providers", "list"] as const; + +/** + * Query options factory for the transcription config. + * Used by the modal to populate initial form state when loading. + */ +function transcriptionConfigQueryOptions() { + return queryOptions({ + queryKey: ["transcription", "config"] as const, + queryFn: () => + api.getTranscriptionConfig().match( + (cfg) => cfg, + // eslint-disable-next-line @typescript-eslint/only-throw-error + (err) => { throw err; }, + ), + }); +} + +/** Query options factory for the provider list (sidebar listing). */ +function providersQueryOptions() { + return queryOptions({ + queryKey: PROVIDERS_QUERY_KEY, + queryFn: () => + api.listProviders().match( + (p) => p, + // eslint-disable-next-line @typescript-eslint/only-throw-error + (err) => { throw err; }, + ), + }); +} + +// ── Preset logic ───────────────────────────────────────────────────────────── + +/** Known preset metadata. Drives default-model placeholder text. */ +const KNOWN_PRESETS: Record = { + groq: { defaultModel: "whisper-large-v3" }, + openai: { defaultModel: "whisper-1" }, +}; + +/** Derive the preset dropdown value from a provider name. */ +function derivePreset(name: string): "groq" | "openai" | "custom" { + if (name === "groq") return "groq"; + if (name === "openai") return "openai"; + return "custom"; +} + +// ── Form state ──────────────────────────────────────────────────────────────── + +interface SettingsFormState { + /** Provider name (the TOML key under `[transcription.providers.*]`). */ + name: string; + /** Preset: groq | openai | custom. */ + preset: "groq" | "openai" | "custom"; + /** Optional model override; empty string = use preset default. */ + model: string; + /** Base URL for custom preset only. */ + baseUrl: string; + /** Auth scheme for custom preset only. */ + authScheme: string; + /** New API key — write-only. Empty = preserve existing keyring entry. */ + apiKey: string; + /** If true, the saved provider becomes active_provider. */ + setAsActive: boolean; +} + +const INITIAL_FORM: SettingsFormState = { + name: "", + preset: "groq", + model: "", + baseUrl: "", + authScheme: "Bearer", + apiKey: "", + setAsActive: false, +}; + +// ── Props ───────────────────────────────────────────────────────────────────── + +/** Props for {@link TranscribeSettingsModal}. */ +export interface TranscribeSettingsModalProps { + /** Whether the modal is visible. */ + open: boolean; + /** Called when the modal should close (Cancel, backdrop, ESC). */ + onClose: () => void; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +/** + * Modal dialog for managing transcription provider configuration. + * + * Renders nothing when `open` is false (unmounts cleanly — no hidden-but- + * mounted pattern). The form is fresh on every open. + * + * Save flow: + * - If `apiKey` is non-empty: call `api.setProviderKey` first. + * - Call `api.updateTranscriptionConfig` with the constructed provider entry. + * - On success: invalidate providers query + call `onClose`. + * - On error: show inline banner; modal stays open so the user can retry. + */ +export function TranscribeSettingsModal({ open, onClose }: TranscribeSettingsModalProps) { + const qc = useQueryClient(); + + // ── Form state ───────────────────────────────────────────────────────────── + // WHY no useEffect reset: when open=false we return null (full unmount), + // so useState initialises fresh on every open. No effect needed. + const [form, setForm] = useState(INITIAL_FORM); + const [saveError, setSaveError] = useState(null); + + // ── Queries ──────────────────────────────────────────────────────────────── + // WHY both queries: providers list → sidebar; config → initial active_provider. + const providersQuery = useQuery(providersQueryOptions()); + // Config query is used to read active_provider for pre-populating the checkbox. + // We don't destructure data — just used as a reference on save. + const configQuery = useQuery(transcriptionConfigQueryOptions()); + + // ── Save mutation ────────────────────────────────────────────────────────── + const saveMutation = useMutation({ + mutationFn: async (f: SettingsFormState) => { + // Step 1: persist key if supplied (write-only; empty = keep existing). + if (f.apiKey.trim() !== "") { + const r1 = await api.setProviderKey(f.name.trim(), f.apiKey.trim()); + if (r1.isErr()) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw r1.error; + } + } + + // Step 2: build the updated TranscriptionConfig. + // WHY refuse on undefined: substituting `{ providers: {} }` would + // wipe every other configured provider on Save (the spread on the + // next block would write a config containing only the new entry). + // The Save button is also gated on configQuery.isSuccess to prevent + // the user reaching this branch in normal use; this is belt + braces. + if (configQuery.data === undefined) { + const err: CoreError = { + kind: "Internal", + data: "Existing transcription config not loaded yet — refusing to overwrite providers.", + }; + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw err; + } + const existingConfig: TranscriptionConfig = configQuery.data; + + const entry: ProviderEntry = { + preset: f.preset, + model: f.model.trim() !== "" ? f.model.trim() : null, + base_url: f.preset === "custom" && f.baseUrl.trim() !== "" ? f.baseUrl.trim() : null, + auth_scheme: f.preset === "custom" && f.authScheme.trim() !== "" ? f.authScheme.trim() : null, + }; + + const updatedConfig: TranscriptionConfig = { + active_provider: f.setAsActive ? f.name.trim() : existingConfig.active_provider, + providers: { + ...existingConfig.providers, + [f.name.trim()]: entry, + }, + }; + + const r2 = await api.updateTranscriptionConfig(updatedConfig); + if (r2.isErr()) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw r2.error; + } + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: PROVIDERS_QUERY_KEY }); + onClose(); + }, + onError: (err: unknown) => { + // err is CoreError (registered as defaultError via queryClient.ts augmentation) + // For non-CoreError shapes (shouldn't happen from our api wrappers), fallback gracefully. + if (typeof err === "object" && err !== null && "kind" in err) { + setSaveError(coreErrorMessage(err as Parameters[0])); + } else { + setSaveError("An unexpected error occurred. Please try again."); + } + }, + }); + + // ── Event handlers ───────────────────────────────────────────────────────── + + function handleKeyDown(e: React.KeyboardEvent) { + // WHY guard: mid-save, ESC must not close the modal (spec §5.3). + if (e.key === "Escape" && !saveMutation.isPending) { + onClose(); + } + } + + function handleBackdropClick() { + // WHY guard: same mid-save lock as ESC. + if (!saveMutation.isPending) { + onClose(); + } + } + + function handlePresetChange(e: React.ChangeEvent) { + const preset = e.target.value as "groq" | "openai" | "custom"; + setForm((prev) => ({ + ...prev, + preset, + // Auto-sync provider name to the preset when the name is empty or matches a known preset. + name: prev.name === "" || prev.name in KNOWN_PRESETS ? preset : prev.name, + })); + } + + function handleNameChange(e: React.ChangeEvent) { + const name = e.target.value; + setForm((prev) => ({ + ...prev, + name, + // Auto-derive preset from name for known values. + preset: derivePreset(name), + })); + } + + // WHY React.SyntheticEvent (not React.FormEvent): FormEvent is deprecated in + // @types/react 19+; SyntheticEvent is the preferred base type for form events. + function handleSave(e: React.SyntheticEvent) { + e.preventDefault(); + setSaveError(null); + saveMutation.mutate(form); + } + + // ── Early exit when closed ───────────────────────────────────────────────── + if (!open) return null; + + // ── Derived values for rendering ─────────────────────────────────────────── + const modelPlaceholder = form.name in KNOWN_PRESETS + ? `Default: ${KNOWN_PRESETS[form.name]!.defaultModel}` + : "Model (optional)"; + + const isPending = saveMutation.isPending; + + // ── Render ───────────────────────────────────────────────────────────────── + return ( + // WHY outer div is not aria-hidden: it is the backdrop but the accessible + // dialog lives inside it. aria-hidden on the backdrop would hide the dialog + // from the accessibility tree. The outer div is inert from the AT perspective + // only because role="dialog" + aria-modal="true" on the inner panel causes + // assistive technology to treat the rest of the page as aria-hidden by spec. +

+ {/* Dialog panel — stop backdrop click propagation from inside */} +
{ e.stopPropagation(); }} + onKeyDown={handleKeyDown} + // WHY tabIndex: div needs focusability so keyDown fires on ESC without + // needing a focusable child to bubble from. + tabIndex={-1} + > + {/* Header */} +

+ Transcription Settings +

+ + {/* Existing providers list */} + {providersQuery.data && providersQuery.data.providers.length > 0 && ( +
+

+ Configured providers +

+
    + {providersQuery.data.providers.map((p) => ( +
  • + +
  • + ))} +
+
+
+ )} + + {/* Error banner */} + {saveError !== null && ( +
+ {saveError} +
+ )} + + {/* Form */} +
+ {/* Provider name */} +
+ + +
+ + {/* Preset dropdown */} +
+ + +
+ + {/* Model (optional) */} +
+ + { setForm((prev) => ({ ...prev, model: e.target.value })); }} + placeholder={modelPlaceholder} + className="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ + {/* Custom-preset-only fields */} + {form.preset === "custom" && ( + <> +
+ + { setForm((prev) => ({ ...prev, baseUrl: e.target.value })); }} + placeholder="https://api.example.com/v1" + className="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ +
+ + +
+ + )} + + {/* API key — write-only password input */} +
+ + {/* WHY type="password": key must never be shown in plaintext. + WHY autoComplete="new-password": prevents browser from auto-filling + existing saved passwords into this field. */} + { setForm((prev) => ({ ...prev, apiKey: e.target.value })); }} + placeholder="••••••••" + autoComplete="new-password" + className="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ + {/* Set as active checkbox */} + + + {/* Actions */} +
+ + {/* WHY disable Save when name empty or config not loaded: an + empty trimmed name would write providers[""] (corrupt config + row); a missing config snapshot would wipe other providers + on the spread. Both are data-integrity hazards, not just + UX nits. */} + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/TranscriptionPill.tsx b/apps/desktop/src/components/TranscriptionPill.tsx new file mode 100644 index 0000000..6ca5549 --- /dev/null +++ b/apps/desktop/src/components/TranscriptionPill.tsx @@ -0,0 +1,208 @@ +/** + * TranscriptionPill — StatusBar widget showing in-flight transcription jobs. + * + * Behaviour summary: + * - Hidden when the `transcription.jobs` slice is empty. + * - Shows "Transcribing (N)" where N = total jobs in slice (all statuses, + * including grace-period terminal ones). The grace timer in useDomainEvents + * auto-removes them; the pill naturally settles back to hidden. + * - Click → absolute-positioned popover listing each job with: + * - File name + * - Status badge ("Queued", "Transcribing 32%", "Done!", "Cancelled", "Failed: …") + * - Cancel button for queued/running jobs (calls api.cancelTranscription) + * - Title tooltip on failed badge (coreErrorMessage of the inner error) + * - Jobs sorted newest-first by started_at_ms. + * - Popover closes on outside click or ESC. + * + * WHY no useMemo / useCallback: React Compiler 1.0 is enabled (CLAUDE.md §Frontend). + * WHY single-property selector for `jobs`: scalar read is a stable reference in + * Zustand 5 without useShallow (CLAUDE.md §Frontend state). + * WHY plain absolute-positioned div (not a library popover): no Radix/shadcn in + * this project; CollisionPill uses a TanStack Link. A minimal controlled div with + * a backdrop click handler matches the project's existing CSS pattern. + * WHY useEffect for ESC listener: keyboard events must be bound on the document, + * not on the pill button, so the popover closes regardless of focus location. + */ +import { useState, useEffect, useRef } from "react"; +import { useUiStore } from "../stores/ui"; +import { coreErrorMessage } from "../lib/coreError"; +import * as api from "../api"; +import type { TranscriptionJob, TranscriptionJobStatus } from "../stores/ui"; + +// ── Status badge ───────────────────────────────────────────────────────────── + +/** + * Returns a short display label for a {@link TranscriptionJobStatus}. + * Used for the badge text in the popover rows. + */ +function statusLabel(status: TranscriptionJobStatus): string { + switch (status.kind) { + case "queued": + return `Queued #${String(status.queue_position)}`; + case "running": { + const percent = + status.total_ms !== null && status.total_ms > 0 + ? Math.round((status.processed_ms / status.total_ms) * 100) + : null; + return percent !== null ? `Transcribing ${String(percent)}%` : "Transcribing…"; + } + case "completed": + return "Done!"; + case "cancelled": + return "Cancelled"; + case "failed": + return "Failed"; + } +} + +/** True when the job can still be cancelled (not yet in a terminal state). */ +function isCancellable(status: TranscriptionJobStatus): boolean { + return status.kind === "queued" || status.kind === "running"; +} + +// ── Job row ─────────────────────────────────────────────────────────────────── + +interface JobRowProps { + job: TranscriptionJob; +} + +/** Single row in the popover — file name, status badge, optional Cancel. */ +function JobRow({ job }: JobRowProps) { + const notifyError = useUiStore((s) => s.notifyError); + const { status } = job; + + const label = statusLabel(status); + // WHY title on failed badge only: only failed jobs carry actionable error text. + const failedTitle = + status.kind === "failed" + ? coreErrorMessage({ kind: "Transcription", data: status.error }) + : undefined; + + function onCancel() { + api + .cancelTranscription(job.request_uuid) + .mapErr((err) => { notifyError(err); }); + } + + return ( +
+ + {job.file_name} + +
+ + {label} + + {isCancellable(status) && ( + + )} +
+
+ ); +} + +// ── Pill ───────────────────────────────────────────────────────────────────── + +/** + * TranscriptionPill — reads `s.transcription.jobs` from the Zustand store + * and renders a count pill + popover for the StatusBar footer. + * + * @example + * ```tsx + * + * + * ``` + */ +export function TranscriptionPill() { + const jobs = useUiStore((s) => s.transcription.jobs); + const jobList = Object.values(jobs); + + const [open, setOpen] = useState(false); + const pillRef = useRef(null); + + // WHY close on outside click: standard popover UX — clicking any element + // outside the pill+popover container collapses the panel. + useEffect(() => { + if (!open) return; + + function onPointerDown(e: PointerEvent) { + if (pillRef.current && !pillRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("pointerdown", onPointerDown); + return () => { document.removeEventListener("pointerdown", onPointerDown); }; + }, [open]); + + // WHY ESC listener: keyboard accessibility — ESC should always close the + // popover regardless of focus position, matching the platform convention. + useEffect(() => { + if (!open) return; + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + } + } + document.addEventListener("keydown", onKeyDown); + return () => { document.removeEventListener("keydown", onKeyDown); }; + }, [open]); + + // Hidden when no jobs are in the slice. + if (jobList.length === 0) return null; + + // Newest first. + const sorted = [...jobList].sort((a, b) => b.started_at_ms - a.started_at_ms); + + return ( +
+ {/* Pill trigger */} + + + {/* Popover panel */} + {open && ( +
+
+ Transcription jobs +
+ {sorted.map((job) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/hooks/useDomainEvents.ts b/apps/desktop/src/hooks/useDomainEvents.ts index 1d4902c..61534ae 100644 --- a/apps/desktop/src/hooks/useDomainEvents.ts +++ b/apps/desktop/src/hooks/useDomainEvents.ts @@ -23,20 +23,36 @@ import { filesKeys } from "../queries/files"; import { tagsKeys } from "../queries/tags"; import { searchKeys } from "../queries/search"; import { dedupKeys } from "../queries/dedup"; +import { transcriptsKeys } from "../queries/transcripts"; import { useUiStore } from "../stores/ui"; const FILES_DEBOUNCE_MS = 300; +/** Time a `completed` job remains in the slice before auto-removal (ms). */ +const COMPLETED_GRACE_MS = 5_000; +/** Time a `cancelled` job remains in the slice before auto-removal (ms). */ +const CANCELLED_GRACE_MS = 3_000; export function useDomainEvents(): void { const queryClient = useQueryClient(); const notifyError = useUiStore((s) => s.notifyError); const setVerifyBatchProgress = useUiStore((s) => s.setVerifyBatchProgress); const clearVerifyBatch = useUiStore((s) => s.clearVerifyBatch); + // WHY single-property selectors per CLAUDE.md "useShallow for any + // multi-property Zustand v5 selector": the four transcription actions are + // each a single-property pull, so no useShallow is needed. + const startJob = useUiStore((s) => s.transcription.startJob); + const updateJob = useUiStore((s) => s.transcription.updateJob); + const removeJob = useUiStore((s) => s.transcription.removeJob); useEffect(() => { let active = true; let unsubscribe: UnsubscribeFn | null = null; let filesDebounceTimer: ReturnType | null = null; + // WHY tracked Set: terminal-state auto-remove timers must be cleared on + // unmount (StrictMode double-effect; HMR; route teardown). Tracking lets + // the cleanup loop fire `clearTimeout` on every pending grace timer + // without leaking a callback that would mutate a stale slice. + const transcriptionTimers = new Set>(); const invalidateFiles = () => { void queryClient.invalidateQueries({ queryKey: filesKeys.all }); @@ -107,6 +123,83 @@ export function useDomainEvents(): void { clearVerifyBatch(); void queryClient.invalidateQueries({ queryKey: dedupKeys.all }); break; + case "TranscriptionStarted": + // WHY startJob (not updateJob): the worker may have skipped the + // queued state entirely (single-worker queue with no preceding + // dispatch event in v1) — the in-flight slice's `running` entry + // is born here. Status starts at 0/total_ms unknown; the first + // Progress event flips processed_ms forward. + startJob({ + request_uuid: event.data.request_uuid, + file_uuid: event.data.file_uuid, + file_name: event.data.file_name, + status: { kind: "running", processed_ms: 0, total_ms: null }, + started_at_ms: Date.now(), + }); + // Invalidate transcripts query for this file — a future "in-progress" + // row may exist (V013 resumability); v1 has none, so this is a + // forward-compat no-op today. + void queryClient.invalidateQueries({ + queryKey: transcriptsKeys.byFileUuid(event.data.file_uuid), + }); + break; + case "TranscriptionProgress": + updateJob(event.data.request_uuid, { + kind: "running", + processed_ms: event.data.processed_ms, + total_ms: event.data.total_ms, + }); + break; + case "TranscriptionCompleted": { + const { request_uuid, transcript_id, file_uuid, segment_count, language } = event.data; + updateJob(request_uuid, { + kind: "completed", + transcript_id, + segment_count, + language, + }); + // WHY invalidate transcripts (per file) AND search: completion + // inserts a new row that the file-detail sidebar must refresh, + // and `transcript_search` FTS5 changes mean global search may + // surface a new hit. files/tags untouched. + void queryClient.invalidateQueries({ + queryKey: transcriptsKeys.byFileUuid(file_uuid), + }); + void queryClient.invalidateQueries({ queryKey: searchKeys.all }); + // Auto-remove the slice entry after the user has had time to + // see "done!" — terminal completion is celebratory, not blocking. + // The slice's removeJob no-ops if the user dismissed first. + const completedTimer = setTimeout(() => { + removeJob(request_uuid); + transcriptionTimers.delete(completedTimer); + }, COMPLETED_GRACE_MS); + transcriptionTimers.add(completedTimer); + break; + } + case "TranscriptionCancelled": { + const { request_uuid } = event.data; + updateJob(request_uuid, { kind: "cancelled" }); + // Auto-remove a bit faster than completed — cancellation is + // user-initiated; the user already knows; no celebration needed. + const cancelledTimer = setTimeout(() => { + removeJob(request_uuid); + transcriptionTimers.delete(cancelledTimer); + }, CANCELLED_GRACE_MS); + transcriptionTimers.add(cancelledTimer); + break; + } + case "TranscriptionFailed": + updateJob(event.data.request_uuid, { + kind: "failed", + error: event.data.error, + }); + // WHY also notify: failures are user-actionable (Auth, FileTooLarge, + // QuotaExceeded). Surfacing through the toast stack means the user + // sees the typed error even when the StatusBar pill is collapsed. + notifyError({ kind: "Transcription", data: event.data.error }); + // WHY no auto-remove: failures persist until the user dismisses + // them via the popover Dismiss button (spec § "TranscriptionPill"). + break; default: { const _exhaustive: never = event; throw new Error(`Unhandled AppEvent kind: ${JSON.stringify(_exhaustive)}`); @@ -125,7 +218,11 @@ export function useDomainEvents(): void { return () => { active = false; if (filesDebounceTimer) clearTimeout(filesDebounceTimer); + // Clear every pending transcription auto-remove timer to prevent slice + // mutations after unmount. + for (const t of transcriptionTimers) clearTimeout(t); + transcriptionTimers.clear(); if (unsubscribe) unsubscribe(); }; - }, [queryClient, notifyError, setVerifyBatchProgress, clearVerifyBatch]); + }, [queryClient, notifyError, setVerifyBatchProgress, clearVerifyBatch, startJob, updateJob, removeJob]); } diff --git a/apps/desktop/src/lib/__tests__/fixtures.ts b/apps/desktop/src/lib/__tests__/fixtures.ts index 46a315e..5eda54a 100644 --- a/apps/desktop/src/lib/__tests__/fixtures.ts +++ b/apps/desktop/src/lib/__tests__/fixtures.ts @@ -35,6 +35,10 @@ export function file(hash: string, tagIds: string[]): FileWithTagsPayload { mime_type: null, thumbnail_path: null, thumbnail_status: null, + // WHY non-null default (T9 review fix): the fixture pre-dates + // `absolute_path`; tests that exercise the unmounted-volume branch + // construct payloads inline with `absolute_path: null`. + absolute_path: `/mnt/test/${hash}.jpg`, tags: tagIds.map((id) => ({ id, name: `tag-${id}`, diff --git a/apps/desktop/src/lib/coreError.ts b/apps/desktop/src/lib/coreError.ts index 011ab20..387112c 100644 --- a/apps/desktop/src/lib/coreError.ts +++ b/apps/desktop/src/lib/coreError.ts @@ -1,4 +1,9 @@ -import type { BackupFailureReason, CoreError, FullHashUnavailableReason } from "../bindings"; +import type { + BackupFailureReason, + CoreError, + FullHashUnavailableReason, + TranscriptionError, +} from "../bindings"; /** * Returns a human-readable string from a {@link CoreError} data payload. @@ -16,6 +21,9 @@ export function coreErrorMessage(e: CoreError): string { if (e.kind === "BackupFailed") { return backupFailureMessage(e.data.reason); } + if (e.kind === "Transcription") { + return transcriptionErrorMessage(e.data); + } if (typeof e.data === "string") { return e.data; } @@ -57,3 +65,52 @@ function backupFailureMessage(reason: BackupFailureReason): string { } } } + +/** + * Returns a user-friendly string for the inner {@link TranscriptionError} + * carried by `CoreError::Transcription`. Exhaustive `switch (e.kind)` with + * a TypeScript `never` default — adding a new variant in + * `crates/core/src/transcription.rs` without extending this switch becomes + * a compile-time error after `just bindings` regenerates `bindings.ts`. + * + * WHY string-form messages over raw `JSON.stringify(data)`: the discriminant + * payloads (e.g. `RateLimited.retry_after_secs`, `FileTooLarge.limit_bytes`) + * carry information the user can act on; a generic stringification would bury + * the actionable bit in object syntax. + */ +function transcriptionErrorMessage(e: TranscriptionError): string { + switch (e.kind) { + case "Network": + return `Network error reaching transcription provider: ${e.data}`; + case "Auth": + return "Authentication failed — provider rejected the API key. Check the Settings modal."; + case "RateLimited": + return e.data.retry_after_secs !== null + ? `Rate-limited by transcription provider; retry in ${String(e.data.retry_after_secs)}s.` + : "Rate-limited by transcription provider; retry shortly."; + case "QuotaExceeded": + return "Provider quota or billing exhausted. Check the provider's dashboard."; + case "ModelNotFound": + return `Model "${e.data.model}" not available at backend "${e.data.backend}".`; + case "AudioDecode": + return `Could not decode audio from source: ${e.data}`; + case "FileTooLarge": { + // WHY MiB rounded to whole bytes: provider limits are documented in MB + // (Groq/OpenAI: 25 MB), not bytes; users compare against that scale. + const limitMib = Math.round(e.data.limit_bytes / (1024 * 1024)); + return `File too large for provider (limit ${String(limitMib)} MiB).`; + } + case "Cancelled": + return "Transcription cancelled."; + case "BackendUnavailable": + return `Transcription backend unavailable: ${e.data.reason}`; + case "QueueFull": + return `Transcription queue is full (${String(e.data.queued)} jobs queued); try again shortly.`; + case "Internal": + return `Internal transcription error: ${e.data}`; + default: { + const _exhaustive: never = e; + return String(_exhaustive); + } + } +} diff --git a/apps/desktop/src/queries/transcripts.ts b/apps/desktop/src/queries/transcripts.ts new file mode 100644 index 0000000..ef0249f --- /dev/null +++ b/apps/desktop/src/queries/transcripts.ts @@ -0,0 +1,116 @@ +/** + * Transcripts query keys + queryOptions + hook for reading persisted + * `transcript` rows by `file_uuid`. + * + * WHY a separate query module: `useDomainEvents` invalidates this surface on + * `AppEvent::TranscriptionCompleted` for the matching `file_uuid`, so the + * key shape MUST be in one place — sidebar reads + event invalidations both + * derive from `transcriptsKeys.byFileUuid(uuid)`. + * + * WHY `select:` returning only the latest row by `completed_at` DESC: spec + * § "Multi-row selection on the file-detail sidebar" — v1 surfaces a single + * transcript per file (the most recent successful one). A future picker UI + * is queued as a follow-up; until then `select:` collapses the array to one + * row and consumers don't need to handle the multi-row shape. + * + * BACKEND COMMAND PENDING (T9 follow-up): the underlying + * `list_transcripts_by_file_uuid` Tauri command is not yet implemented (T7 + * shipped the eight write-side commands only). The `queryFn` below throws a + * typed `CoreError::Unsupported` so the cache machinery + key shape land + * cleanly today; the first sidebar consumer (T9 / ``) + * surfaces the error and the orchestrator files the command-add issue. This + * keeps `useDomainEvents`'s invalidation arm wired without blocking T8. + */ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import type { CoreError } from "../bindings"; + +/** + * One persisted transcript header row (no segments). + * + * Mirrors the shape `crates/db/src/transcript_repo.rs::TranscriptRow` will + * cross over the IPC boundary as once the backend `list_transcripts_by_file_uuid` + * Tauri command lands. Hand-crafted today because no command emits this + * type yet — replace with a `bindings.ts` import once T9's command lands. + */ +export interface TranscriptHeader { + /** UUIDv7 simple-hex transcript id. */ + id: string; + /** FK to `files.file_uuid`. */ + file_uuid: string; + /** Backend identifier (`provider:model`). */ + backend: string; + /** Detected language (BCP-47 short code) when reported. */ + language: string | null; + /** Total source media duration in milliseconds. */ + duration_ms: number; + /** ISO-8601 completion timestamp. */ + completed_at: string | null; +} + +export const transcriptsKeys = { + /** Root key — invalidate to bust every per-file transcripts query. */ + all: ["transcripts"] as const, + /** Per-file query key. Pairs with `transcriptsQueryOptions(fileUuid)`. */ + byFileUuid: (fileUuid: string) => [...transcriptsKeys.all, "byFileUuid", fileUuid] as const, +} as const; + +/** + * Query options factory: load transcripts for a single file, sorted latest + * first via the `select:` callback. Always returns 0 or 1 rows. + * + * @param fileUuid - FileUuid (immutable surrogate per V011) to fetch transcripts for. + */ +export function transcriptsQueryOptions(fileUuid: string) { + return queryOptions({ + queryKey: transcriptsKeys.byFileUuid(fileUuid), + queryFn: (): Promise => { + // WHY explicit Promise.reject (not `throw` from sync body): TanStack + // Query expects `queryFn` to return a Promise; throwing here would be + // wrapped automatically but the `reject` form keeps the rejection path + // explicit + matches the future `fromInvoke(...)` shape one-to-one. + // WHY CoreError::Unsupported (not Internal): the backend command is + // intentionally absent in v1; the Unsupported variant signals "future + // feature" precisely. Frontend fallback branches on `err.kind`. + const err: CoreError = { + kind: "Unsupported", + data: "list_transcripts_by_file_uuid Tauri command not yet implemented (T9 follow-up)", + }; + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject(err); + }, + /** + * Reduce the multi-row response to the latest by `completed_at` DESC. + * Returns `null` when no transcripts exist for the file. + * + * WHY `select:` (not `queryFn` reduction): keeps the cached entry as the + * full array — a future picker UI can read it without re-fetching — while + * the v1 sidebar consumer gets the single-row view it wants. + */ + select: (rows: TranscriptHeader[]): TranscriptHeader | null => { + if (rows.length === 0) return null; + // WHY copy before sort: sort mutates; mutating a TanStack-cached value + // breaks structural-share invariants. `[...rows]` is the canonical guard. + const sorted = [...rows].sort((a, b) => { + // WHY null pushed to end: in-progress (V013-resumability) transcripts + // have no `completed_at` yet; the sidebar should prefer terminal rows. + if (a.completed_at === null && b.completed_at === null) return 0; + if (a.completed_at === null) return 1; + if (b.completed_at === null) return -1; + // String comparison works on ISO-8601 lexicographically. + return b.completed_at.localeCompare(a.completed_at); + }); + return sorted[0] ?? null; + }, + }); +} + +/** + * Hook: subscribe to the latest transcript for a file. Returns `null` in + * `data` when no transcripts exist; `error: CoreError | null` follows the + * registered defaultError type (CoreError per `queryClient.ts`). + * + * @param fileUuid - FileUuid for the target file. + */ +export function useTranscriptsByFileUuid(fileUuid: string) { + return useQuery(transcriptsQueryOptions(fileUuid)); +} diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index d02bf22..3b7185f 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -19,7 +19,14 @@ */ import { create, type StateCreator } from "zustand"; import { coreErrorMessage } from "../lib/coreError"; -import type { BatchId, CoreError, FileUuid, FullHashOutcome, ScanReport } from "../bindings"; +import type { + BatchId, + CoreError, + FileUuid, + FullHashOutcome, + ScanReport, + TranscriptionError, +} from "../bindings"; type ViewMode = "table" | "grid"; @@ -92,7 +99,87 @@ interface SelectionSlice { setSelectedFileUuid: (uuid: FileUuid | null) => void; } -export type UiStore = ViewSlice & SearchSlice & ScanSlice & NotificationsSlice & VerifyBatchSlice & SelectionSlice; +/** + * Discriminated-union status of a single in-flight transcription job. + * + * Wire shape mirrors `AppEvent::Transcription*` payloads (see bindings.ts): + * each event arm folds into one status variant via `useDomainEvents`. + * + * WHY discriminated union (not flat fields): `progressPercent: number | null` + * + `error: TranscriptionError | null` (the spec's flat shape) loses the + * compile-time guarantee that "completed" jobs have a `transcript_id` and + * "failed" jobs have an `error`. The discriminated union lets components + * pattern-match without nullable-field-juggling. + */ +export type TranscriptionJobStatus = + | { kind: "queued"; queue_position: number } + | { kind: "running"; processed_ms: number; total_ms: number | null } + | { + kind: "completed"; + transcript_id: string; + segment_count: number; + language: string | null; + } + | { kind: "cancelled" } + | { kind: "failed"; error: TranscriptionError }; + +/** + * One in-flight (or recently-terminated) transcription job. + * + * Lifecycle: created on `AppEvent::TranscriptionStarted`; mutated on every + * `Transcription{Progress,Completed,Cancelled,Failed}` for the same + * `request_uuid`; auto-removed by `useDomainEvents` after a terminal-state + * grace period (5s for Completed, 3s for Cancelled). `failed` jobs persist + * until the user dismisses them via `removeJob`. + */ +export interface TranscriptionJob { + /** Per-request UUIDv7 (lowercase-hex simple form). The map key. */ + request_uuid: string; + /** Stable file surrogate (FileUuid) the job is transcribing. */ + file_uuid: string; + /** Display name (basename or curated label) for UI surfacing. */ + file_name: string; + /** Current discriminated-union status. */ + status: TranscriptionJobStatus; + /** `Date.now()` captured at `startJob` time; used for relative timestamps. */ + started_at_ms: number; +} + +/** + * In-flight transcription jobs, keyed by `request_uuid`. + * + * WHY a `Record` (not an array): `useDomainEvents` looks up by request_uuid + * on every Progress / Completed / Cancelled / Failed event; an array would + * force an O(n) `find` per event. The map keeps mutation O(1) and matches + * the natural identity of a job (its UUIDv7). + * + * WHY no Zustand `persist`: terminal jobs are intentionally lost on app + * restart — they're ephemeral UX, not server state. The DB owns durable + * `transcript` rows; this slice is the in-flight progress mirror. + * + * WHY `updateJob` and `removeJob` are no-ops on missing keys: the + * auto-remove timer in `useDomainEvents` (5s post-Completed, 3s post- + * Cancelled) can race with a user-initiated `removeJob` from the + * `` popover. Defensive no-ops collapse the race + * without requiring lookup-then-update at every call site. + */ +export interface TranscriptionSlice { + /** Map of in-flight + recently-terminated jobs. */ + transcription: { + jobs: Record; + startJob: (job: TranscriptionJob) => void; + updateJob: (request_uuid: string, status: TranscriptionJobStatus) => void; + removeJob: (request_uuid: string) => void; + }; +} + +export type UiStore = ViewSlice + & SearchSlice + & ScanSlice + & NotificationsSlice + & VerifyBatchSlice + & SelectionSlice + & TranscriptionSlice; const createViewSlice: StateCreator = (set) => ({ viewMode: "table", @@ -156,6 +243,43 @@ const createSelectionSlice: StateCreator = (set setSelectedFileUuid: (uuid) => { set({ selectedFileUuid: uuid }); }, }); +const createTranscriptionSlice: StateCreator = (set) => ({ + transcription: { + jobs: {}, + startJob: (job) => { + set((s) => ({ + transcription: { + ...s.transcription, + jobs: { ...s.transcription.jobs, [job.request_uuid]: job }, + }, + })); + }, + updateJob: (request_uuid, status) => { + set((s) => { + const existing = s.transcription.jobs[request_uuid]; + // WHY no-op on missing key: see TranscriptionSlice doc — auto-remove + // timers in `useDomainEvents` race with user-initiated removeJob. + if (!existing) return s; + return { + transcription: { + ...s.transcription, + jobs: { ...s.transcription.jobs, [request_uuid]: { ...existing, status } }, + }, + }; + }); + }, + removeJob: (request_uuid) => { + set((s) => { + if (!(request_uuid in s.transcription.jobs)) return s; + // WHY destructure-and-discard: produces a shallow copy without the + // removed key; immutable update keeps Zustand subscribers happy. + const { [request_uuid]: _removed, ...rest } = s.transcription.jobs; + return { transcription: { ...s.transcription, jobs: rest } }; + }); + }, + }, +}); + export const useUiStore = create()((...a) => ({ ...createViewSlice(...a), ...createSearchSlice(...a), @@ -163,4 +287,5 @@ export const useUiStore = create()((...a) => ({ ...createNotificationsSlice(...a), ...createVerifyBatchSlice(...a), ...createSelectionSlice(...a), + ...createTranscriptionSlice(...a), })); diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index b4e555b..ec7764a 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -15,17 +15,40 @@ publish = false # WHY: intra-workspace only; not published to crates.io specta = ["dep:specta", "perima-core/specta"] [dependencies] -perima-core = { path = "../core" } -perima-fs = { path = "../fs" } -perima-hash = { path = "../hash" } -perima-media = { workspace = true } +perima-core = { path = "../core" } +perima-fs = { path = "../fs" } +perima-hash = { path = "../hash" } +perima-media = { workspace = true } +# WHY perima-transcribe in app: TranscriptionUseCase composes the transcriber +# registry + audio pipeline at AppContainer build time. The use-case lives in +# crates/app (alongside the other UseCases); the adapter crate stays the +# pure transcription-port impl. No reverse dep — perima-transcribe does NOT +# depend on perima-app. +perima-transcribe = { path = "../transcribe" } +# WHY perima-db in (non-test) deps: TranscriptionUseCase calls +# `SqliteTranscriptRepository::insert_with_request_uuid` directly because the +# transcript repo is intentionally NOT a port (sole consumer is the use-case; +# adding a port trait would be premature abstraction per the spec's +# "transcript_repo intentionally adapter-only" note). +perima-db = { path = "../db" } async-broadcast = { workspace = true } async-trait = { workspace = true } directories.workspace = true futures = { workspace = true } +# WHY flume: TranscriptionUseCase owns a flume::bounded(32) job queue; the +# producer side hands `QueueItem` to the worker task spawned at construction. +flume.workspace = true +# WHY keyring: AppContainer wiring resolves per-provider API keys from the +# OS keyring before instantiating each transcriber. Skipping a provider +# whose key is missing is the correct first-run posture — a Tauri-side +# settings command (T7) writes the key, then the user re-opens. +keyring.workspace = true tokio.workspace = true tokio-util.workspace = true thiserror.workspace = true +# WHY toml: transcription provider config is a TOML file. NOT a workspace- +# wide dep — only crates/app reads the user's `config.toml`. +toml.workspace = true tracing.workspace = true tracing-appender.workspace = true tracing-subscriber.workspace = true diff --git a/crates/app/src/bus.rs b/crates/app/src/bus.rs index 9776ddc..5838fcb 100644 --- a/crates/app/src/bus.rs +++ b/crates/app/src/bus.rs @@ -118,5 +118,10 @@ const fn event_kind(e: &AppEvent) -> &'static str { AppEvent::IndexInvalidated { .. } => "IndexInvalidated", AppEvent::VerifyProgress { .. } => "VerifyProgress", AppEvent::VerifyComplete { .. } => "VerifyComplete", + AppEvent::TranscriptionStarted { .. } => "TranscriptionStarted", + AppEvent::TranscriptionProgress { .. } => "TranscriptionProgress", + AppEvent::TranscriptionCompleted { .. } => "TranscriptionCompleted", + AppEvent::TranscriptionCancelled { .. } => "TranscriptionCancelled", + AppEvent::TranscriptionFailed { .. } => "TranscriptionFailed", } } diff --git a/crates/app/src/config.rs b/crates/app/src/config/mod.rs similarity index 90% rename from crates/app/src/config.rs rename to crates/app/src/config/mod.rs index ae05f3d..2724e9d 100644 --- a/crates/app/src/config.rs +++ b/crates/app/src/config/mod.rs @@ -1,7 +1,13 @@ -//! Cross-shell data-directory resolution. +//! Cross-shell data-directory resolution + per-feature config submodules. //! //! Both `perima` (CLI) and `perima-desktop` call [`resolve_data_dir`] so a //! tag added via one shell is visible in the other. Closes GH #154. +//! +//! # Submodules +//! +//! - [`transcription`] — TOML-backed transcription provider config. + +pub mod transcription; use std::path::PathBuf; diff --git a/crates/app/src/config/transcription.rs b/crates/app/src/config/transcription.rs new file mode 100644 index 0000000..a7fa8b2 --- /dev/null +++ b/crates/app/src/config/transcription.rs @@ -0,0 +1,200 @@ +//! Transcription provider config (TOML-backed). Loaded at app startup; +//! parsed via `toml`. NOT in `crates/core` because core is framework-free +//! (no `toml` dep allowed). +//! +//! Schema: see spec § "Settings + auth". + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use perima_core::CoreError; + +/// Wrapper for the top-level on-disk `config.toml` shape. +/// +/// WHY a private wrapper struct: the on-disk file may carry tables unrelated +/// to `[transcription]` (e.g. future `[telemetry]`). Parsing through a wrapper +/// that ignores unknown sections preserves them on round-trip when callers +/// go through `save` immediately afterwards. +#[derive(Deserialize)] +struct Root { + transcription: Option, +} + +/// Top-level transcription config (one TOML table). +/// +/// Lives at `/config.toml` under the `[transcription]` table. +/// +/// WHY `specta::Type` (T7): the desktop `update_transcription_config` Tauri +/// command receives this struct directly (Batch D contract: derive specta on +/// core/app types that cross IPC, no shell-side wire-type mirror unless +/// needed). `get_transcription_config` returns it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct TranscriptionConfig { + /// Active provider name (must match one of `providers.*` keys). + /// Optional so first-run state (no provider configured yet) is + /// representable without `Option` at the caller. + pub active_provider: Option, + /// Per-provider configuration table. Empty = no providers wired up yet. + #[serde(default)] + pub providers: HashMap, +} + +/// One provider's TOML entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct ProviderEntry { + /// Preset name from `KNOWN_PROVIDERS` (e.g. "groq", "openai", "custom"). + pub preset: String, + /// Optional model override; falls back to the preset's default. + pub model: Option, + /// Optional `base_url` override (required for `preset = "custom"`). + pub base_url: Option, + /// Auth scheme override for custom providers: + /// `"bearer"` | `"x-api-key:
"` | `"none"`. + pub auth_scheme: Option, +} + +impl TranscriptionConfig { + /// Load from `/config.toml`, parsing the `[transcription]` + /// table only. + /// + /// Returns [`Self::default()`] if the file is missing or the table is + /// absent — that is not an error, just "no providers configured yet." + /// + /// # Errors + /// Returns [`CoreError::Internal`] if the file exists but is malformed + /// TOML or unreadable. + pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("config.toml"); + if !path.exists() { + return Ok(Self::default()); + } + let body = std::fs::read_to_string(&path).map_err(|e| { + CoreError::Internal(format!("read transcription config {}: {e}", path.display())) + })?; + let root: Root = toml::from_str(&body).map_err(|e| { + CoreError::Internal(format!( + "parse transcription config {}: {e}", + path.display() + )) + })?; + Ok(root.transcription.unwrap_or_default()) + } + + /// Persist to `/config.toml`. Replaces only the + /// `[transcription]` table; preserves any other tables already on disk. + /// + /// # Errors + /// Returns [`CoreError::Internal`] for I/O or TOML serialization failures. + pub fn save(&self, config_dir: &Path) -> Result<(), CoreError> { + let path = config_dir.join("config.toml"); + // Read the existing file (if any), merge the transcription table, write back. + let existing: toml::Value = if path.exists() { + let body = std::fs::read_to_string(&path).map_err(|e| { + CoreError::Internal(format!("read transcription config {}: {e}", path.display())) + })?; + // WHY unwrap_or_else: a malformed pre-existing config.toml should + // not block writing `[transcription]`; we treat it as empty for + // the merge. The caller (settings UI) is the recovery path for + // a corrupted file outside our table. + toml::from_str(&body).unwrap_or_else(|_| toml::Value::Table(toml::value::Table::new())) + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let mut table = match existing { + toml::Value::Table(t) => t, + _ => toml::value::Table::new(), + }; + let our_value = toml::Value::try_from(self) + .map_err(|e| CoreError::Internal(format!("serialize transcription config: {e}")))?; + table.insert("transcription".to_owned(), our_value); + let body = toml::to_string_pretty(&toml::Value::Table(table)) + .map_err(|e| CoreError::Internal(format!("encode transcription config: {e}")))?; + std::fs::create_dir_all(config_dir).map_err(|e| { + CoreError::Internal(format!("mkdir config dir {}: {e}", config_dir.display())) + })?; + std::fs::write(&path, body).map_err(|e| { + CoreError::Internal(format!( + "write transcription config {}: {e}", + path.display() + )) + })?; + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. +mod tests { + use super::*; + + #[test] + fn load_returns_default_when_file_missing() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = TranscriptionConfig::load(tmp.path()).unwrap(); + assert!(cfg.active_provider.is_none()); + assert!(cfg.providers.is_empty()); + } + + #[test] + fn save_then_load_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = TranscriptionConfig { + active_provider: Some("groq".to_owned()), + ..Default::default() + }; + cfg.providers.insert( + "groq".to_owned(), + ProviderEntry { + preset: "groq".to_owned(), + model: Some("whisper-large-v3-turbo".to_owned()), + base_url: None, + auth_scheme: None, + }, + ); + + cfg.save(tmp.path()).unwrap(); + let round = TranscriptionConfig::load(tmp.path()).unwrap(); + assert_eq!(round.active_provider.as_deref(), Some("groq")); + let entry = round.providers.get("groq").expect("groq provider missing"); + assert_eq!(entry.preset, "groq"); + assert_eq!(entry.model.as_deref(), Some("whisper-large-v3-turbo")); + } + + #[test] + fn save_preserves_unrelated_tables() { + let tmp = tempfile::tempdir().unwrap(); + // Pre-write an unrelated table to ensure save() doesn't clobber it. + let path = tmp.path().join("config.toml"); + std::fs::write(&path, "[other]\nfoo = \"bar\"\n").unwrap(); + + let cfg = TranscriptionConfig { + active_provider: Some("openai".to_owned()), + providers: HashMap::new(), + }; + cfg.save(tmp.path()).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert!( + body.contains("[other]") && body.contains("foo"), + "save() must not clobber unrelated tables; got body:\n{body}" + ); + assert!( + body.contains("[transcription]"), + "save() must add [transcription]; got body:\n{body}" + ); + } + + #[test] + fn load_returns_internal_error_on_malformed_toml() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + std::fs::write(&path, "this is = not = valid = toml").unwrap(); + + let err = TranscriptionConfig::load(tmp.path()).expect_err("malformed TOML must error"); + assert!(matches!(err, CoreError::Internal(_)), "got {err:?}"); + } +} diff --git a/crates/app/src/container.rs b/crates/app/src/container.rs index 51ad0f1..b83e971 100644 --- a/crates/app/src/container.rs +++ b/crates/app/src/container.rs @@ -22,17 +22,32 @@ use std::sync::Arc; +use perima_core::transcription::{BackendId, TranscriptionError}; use perima_core::{ - FileRepository, HashService, IdentityCacheRepository, MetadataRepository, Scanner, - SearchRepository, TagRepository, VolumeRepository, events::EventBus, ports::DatabaseAdmin, + CoreError, DeviceId, FileRepository, HashService, IdentityCacheRepository, MetadataRepository, + Scanner, SearchRepository, TagRepository, VolumeRepository, events::EventBus, + ports::DatabaseAdmin, }; +use perima_db::transcript_repo::SqliteTranscriptRepository; use perima_media::ThumbnailGenerator; +use perima_transcribe::audio::AudioPipeline; +use perima_transcribe::openai_compat::OpenAICompatibleTranscriber; +use perima_transcribe::registry::TranscriberRegistry; +use crate::config::transcription::TranscriptionConfig; use crate::{ Bus, ComputeFullHashUseCase, DedupUseCase, MetadataUseCase, ScanUseCase, SearchUseCase, - TagUseCase, VolumeUseCase, events::EventHandler, + TagUseCase, TranscriptionUseCase, VolumeUseCase, events::EventHandler, }; +/// Keyring service name used for every transcription provider entry. +/// +/// One entry per provider, keyed by the provider's TOML name (`groq`, +/// `openai`, `custom-...`). Frontend (T7) writes via `set_provider_key`; +/// the container reads here at startup. CLI's `auth` subcommand +/// imports this constant to guarantee both writers + reader agree. +pub const KEYRING_SERVICE: &str = "perima.transcription"; + // --------------------------------------------------------------------------- // AppDeps // --------------------------------------------------------------------------- @@ -82,6 +97,34 @@ pub struct AppDeps { pub scanner: Arc, /// Concrete thumbnail generator (not a port — no abstraction yet). pub thumbnailer: Arc, + /// Concrete transcript repository. + /// + /// WHY a concrete `Arc` (not a port): the + /// transcript repo is intentionally adapter-only in the v1 + /// transcription slice — sole consumer is [`TranscriptionUseCase`] and + /// adding a port trait would be premature abstraction. Mirrors the + /// `Arc` pattern above. + pub transcript_repo: Arc, + /// Audio extraction pipeline (ffmpeg shim). + /// + /// WHY a port + Arc: the CLI uses `CliFfmpegInvoker` (PATH discovery), + /// the desktop uses `DesktopFfmpegInvoker` (Tauri sidecar resolver); + /// each shell wires the concrete pipeline at startup. The container + /// holds the abstract port so the use-case stays adapter-agnostic. + pub audio_pipeline: Arc, + /// User-config directory containing `config.toml`. + /// + /// WHY in [`AppDeps`]: [`TranscriptionConfig::load`] needs it at + /// container build time. CLI resolves via [`directories::ProjectDirs`]; + /// desktop reuses the Tauri-resolved `app_data_dir`. + pub config_dir: std::path::PathBuf, + /// Stable device identifier for CRDT row stamping. + /// + /// WHY in [`AppDeps`]: every transcript row needs a `device_id` + /// (CRDT discipline). The CLI / desktop already resolve this at + /// startup via their per-shell `Config::device_id` field; the + /// container threads it into the use-case for writer-cmd payloads. + pub device_id: DeviceId, } impl std::fmt::Debug for AppDeps { @@ -123,6 +166,9 @@ pub struct AppContainer { pub compute_full_hash: Arc, /// [`DedupUseCase`] — quick-hash collision listing + verified-distinct flips. pub dedup: Arc, + /// [`TranscriptionUseCase`] — provider registry + queue + worker + /// orchestration. Owns the single `tokio::spawn`-backed worker task. + pub transcription: Arc, /// Shared event bus — same `Arc` used inside every `UseCase`. pub events: Arc, /// Direct handle to the volume repository port. @@ -224,8 +270,32 @@ impl AppContainer { // so by-value move here is cheap. #[allow(clippy::needless_pass_by_value)] pub fn new(deps: AppDeps, handlers: Vec>) -> Arc { - // Single Bus construction site — spec §2.1 + Batch B/C invariant. - let bus: Arc = Bus::new(); + Self::new_with_bus(deps, handlers, Bus::new()) + } + + /// Build the container atop a pre-existing [`Bus`]. + /// + /// WHY this overload (T6): the writer actor is constructed in the shell + /// BEFORE `AppContainer::new` runs. To make + /// `AppEvent::TranscriptionCompleted` (emitted from the writer post-COMMIT) + /// reach the same handlers as the use-case-emitted events, the shell + /// must construct the [`Bus`] up-front and pass it BOTH to + /// `SqliteWriter::start` AND to `AppContainer::new_with_bus`. + /// The pre-T6 single-construction-site invariant survives — the bus is + /// still created in exactly one place per process; the construction + /// site just moves from inside `AppContainer::new` into the shell. + /// + /// # Panics + /// + /// Must be called from within a tokio runtime context — `tokio::spawn` + /// requires it. + #[must_use] + #[allow(clippy::needless_pass_by_value)] + pub fn new_with_bus( + deps: AppDeps, + handlers: Vec>, + bus: Arc, + ) -> Arc { let events: Arc = bus.clone(); // Spawn one tokio task per handler. Each task owns its own @@ -279,6 +349,27 @@ impl AppContainer { deps.data_dir.clone(), )); + // Transcription registry + use-case wiring. + // + // WHY here (not lazily on first transcribe): the keyring lookup + // is fast but takes a syscall per provider; doing it once at + // container build time keeps the per-job hot path free of + // sync-blocking work and surfaces config errors at startup. + let transcription = build_transcription_use_case(&deps, Arc::clone(&events)) + .unwrap_or_else(|e| { + // WHY non-fatal: a misconfigured provider must not block + // the rest of the app from starting. The use-case still + // exists with an empty registry; first `Start` returns + // BackendUnavailable which the UI can surface specifically. + tracing::warn!(error = %e, "transcription wiring failed; use-case will reject new jobs until fixed"); + Arc::new(TranscriptionUseCase::new( + Arc::new(TranscriberRegistry::new()), + Arc::clone(&deps.transcript_repo), + Arc::clone(&events), + deps.device_id.0.simple().to_string(), + )) + }); + // WHY clone: the same `Arc` lives inside // `VolumeUseCase` (above) AND on the container field so shell // sites that need `find_or_create` can reach it without a @@ -312,6 +403,7 @@ impl AppContainer { metadata, compute_full_hash, dedup, + transcription, events, volumes, tags, @@ -322,6 +414,144 @@ impl AppContainer { } } +// --------------------------------------------------------------------------- +// Transcription wiring +// --------------------------------------------------------------------------- + +/// Build the transcription [`TranscriberRegistry`] from the user's +/// [`TranscriptionConfig`] + keyring entries, then construct the +/// [`TranscriptionUseCase`]. See spec § "Settings + auth". +/// +/// Providers without a keyring entry are silently skipped — registering a +/// keyless backend would surface as a misleading `Auth` error on first +/// transcribe. +/// +/// # Errors +/// +/// - Config TOML missing or malformed — propagated as +/// [`CoreError::Internal`] from [`TranscriptionConfig::load`]. +/// - Adapter construction — [`OpenAICompatibleTranscriber::new`] returns +/// `CoreError::Transcription` for invalid header values etc. +/// - Active-provider lookup — if the configured `active_provider` is +/// missing from `[transcription.providers.*]` or has no keyring entry, +/// returns `BackendUnavailable`. +fn build_transcription_use_case( + deps: &AppDeps, + events: Arc, +) -> Result, CoreError> { + let transcription_config = TranscriptionConfig::load(&deps.config_dir)?; + let mut registry = TranscriberRegistry::new(); + let runtime = tokio::runtime::Handle::current(); + + for (name, entry) in &transcription_config.providers { + let base_preset = + perima_transcribe::providers::find_preset(&entry.preset).ok_or_else(|| { + CoreError::Transcription(TranscriptionError::BackendUnavailable { + reason: format!("unknown preset {} for provider {name}", entry.preset), + }) + })?; + // WHY entry.base_url overlay (Box::leak): `ProviderPreset.base_url` is + // `&'static str` (the public preset table is allocation-free), but + // `preset = "custom"` ships an empty base_url that the user MUST + // override via TOML. Leaking the overlay string once at startup is + // the simplest path to a runtime-keyed preset; the leaked memory + // lives until process exit. Without this overlay, custom providers + // fail at the HTTP layer with an empty base URL. + // + // WHY also override `name`: the registered BackendId is + // `format!("{preset.name}:{model}")` (built inside + // `OpenAICompatibleTranscriber::new`) but `set_active` below looks up + // `format!("{active_name}:{model}")` where `active_name` is the + // user's `[transcription.providers.]` key. These must match. + // Pre-Batch-T6 the two were the same string only by convention + // (e.g. provider name "groq" + preset name "groq"). For + // `preset = "custom"` and provider name "my-server" they diverge — + // so we leak the user's name and stamp it on the runtime preset. + let leaked_name: &'static str = Box::leak(name.clone().into_boxed_str()); + let preset_owned = perima_transcribe::providers::ProviderPreset { + name: leaked_name, + base_url: match &entry.base_url { + Some(url) if !url.is_empty() => Box::leak(url.clone().into_boxed_str()), + _ => base_preset.base_url, + }, + default_model: base_preset.default_model, + file_size_limit_bytes: base_preset.file_size_limit_bytes, + auth_scheme: base_preset.auth_scheme, + }; + // WHY skip on missing keyring entry: registering a keyless backend + // surfaces later as a misleading Auth error on the first transcribe. + // Silently skipping leaves the slot open for the user to set the key + // via the settings UI (T7) and re-launch. + // + // WHY the env-var test seam: `keyring::mock` is process-local and + // cannot be pre-seeded across a CLI subprocess boundary. The + // `PERIMA_TEST_API_KEY_` env var bypasses the keyring + // lookup ONLY in test runs (developers should not set this); the + // CLI integration tests use it to drive the wiremock-backed + // happy-path / auth-failure flows without touching the OS keyring. + let api_key = match std::env::var(format!("PERIMA_TEST_API_KEY_{name}")) { + Ok(v) if !v.is_empty() => v, + _ => match keyring::Entry::new(KEYRING_SERVICE, name) { + Ok(entry) => match entry.get_password() { + Ok(k) => k, + Err(e) => { + tracing::info!(provider = %name, error = %e, "no keyring entry; skipping provider"); + continue; + } + }, + Err(e) => { + tracing::warn!(provider = %name, error = %e, "keyring entry construction failed; skipping provider"); + continue; + } + }, + }; + let backend = OpenAICompatibleTranscriber::new( + &preset_owned, + api_key, + entry.model.clone(), + runtime.clone(), + Arc::clone(&deps.audio_pipeline), + )?; + registry.register(Arc::new(backend)); + } + + if let Some(active_name) = transcription_config.active_provider.as_deref() { + // PINNED: BackendId resolution rule from the spec — the registered + // BackendId is `format!("{provider_name}:{model}")`. Active-provider + // lookup must use the same formula. + let entry = transcription_config.providers.get(active_name).ok_or_else(|| { + CoreError::Transcription(TranscriptionError::BackendUnavailable { + reason: format!( + "active_provider {active_name} has no [transcription.providers.{active_name}] section" + ), + }) + })?; + let preset = perima_transcribe::providers::find_preset(&entry.preset).ok_or_else(|| { + CoreError::Transcription(TranscriptionError::BackendUnavailable { + reason: format!("unknown preset {} for provider {active_name}", entry.preset), + }) + })?; + let model = entry + .model + .clone() + .unwrap_or_else(|| preset.default_model.to_owned()); + let id = BackendId(format!("{active_name}:{model}")); + registry.set_active(id)?; + } + + Ok(Arc::new(TranscriptionUseCase::new( + Arc::new(registry), + Arc::clone(&deps.transcript_repo), + events, + // WHY simple-hex stringification: the writer-cmd's `device` field + // is a String (not DeviceId) since the writer crosses a thread + // boundary and DeviceId is not currently part of the writer's + // payload schema. Mirrors the existing `DeviceId.0.simple()` + // pattern used by other writer-cmd construction sites. + deps.device_id.0.simple().to_string(), + ))) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -333,6 +563,7 @@ mod tests { use std::time::Duration; use perima_core::{AppEvent, FileEvent, MediaPath, VolumeId}; + use perima_db::transcript_repo::SqliteTranscriptRepository; use perima_db::{ ReadPool, SqliteDatabaseAdmin, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository, SqliteSearchRepository, SqliteTagRepository, @@ -340,10 +571,27 @@ mod tests { }; use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; + use perima_transcribe::audio::{AudioError, AudioPipeline}; use tempfile::TempDir; use super::*; + /// Stub audio pipeline that always errors. Used by container tests + /// that don't actually run a transcription — registry construction + /// only touches the pipeline if a provider has a keyring entry, + /// which the test environment never has. + struct StubAudioPipeline; + + impl AudioPipeline for StubAudioPipeline { + fn remux_for_upload( + &self, + _input: &std::path::Path, + _cancel: &tokio_util::sync::CancellationToken, + ) -> Result { + Err(AudioError::BinaryNotFound("test stub".into())) + } + } + /// Records every event it receives. Used to assert fan-out via the /// new `Bus` + `EventHandler` wiring. Async `handle` matches the /// post-Task-6 trait shape (Batch E §2.2). @@ -405,14 +653,19 @@ mod tests { )); let search: Arc = Arc::new(SqliteSearchRepository::new(writer.sender(), reads.clone())); - let identity_cache: Arc = - Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads)); + let identity_cache: Arc = Arc::new( + SqliteIdentityCacheRepository::new(writer.sender(), reads.clone()), + ); let hasher: Arc = Arc::new(Blake3Service::new()); let scanner: Arc = Arc::new(WalkdirScanner::new()); let thumbnailer: Arc = Arc::new(ThumbnailGenerator::disabled()); + let transcript_repo: Arc = + Arc::new(SqliteTranscriptRepository::new(writer.sender(), reads)); + let audio_pipeline: Arc = Arc::new(StubAudioPipeline); let admin: Arc = Arc::new(SqliteDatabaseAdmin::new(writer.sender())); let data_dir = db_tmp.path().to_path_buf(); + let config_dir = db_tmp.path().to_path_buf(); ( db_tmp, @@ -428,6 +681,10 @@ mod tests { hasher, scanner, thumbnailer, + transcript_repo, + audio_pipeline, + config_dir, + device_id: DeviceId::new(), }, writer, ) @@ -455,8 +712,8 @@ mod tests { // The container's `events` field is the single shared `Bus`; // each UseCase receives an `Arc::clone` of it. After construction, // the strong count on `container.events` reflects the shared - // ownership: 1 (container) + 7 (one per UseCase: scan, search, tag, - // volume, metadata, compute_full_hash, dedup) = 8. + // ownership: 1 (container) + 8 (one per UseCase: scan, search, tag, + // volume, metadata, compute_full_hash, dedup, transcription) = 9. let (_db_tmp, deps, _writer) = deps_harness(); // Pass a recording handler so we can observe fan-out from the @@ -469,7 +726,7 @@ mod tests { let events_strong = Arc::strong_count(&container.events); assert_eq!( - events_strong, 8, + events_strong, 9, "container.events should be Arc-cloned once per UseCase plus the container field" ); diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index a1f5f24..ab02a91 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -37,12 +37,13 @@ pub mod scan; pub mod search; pub mod tag; pub mod telemetry; +pub mod transcription; pub mod volume; pub use backfill::{BackfillRate, BackfillReport, BackfillRow, QuickHashBackfillWorker}; pub use backup::{BackupCommand, BackupDatabaseUseCase, BackupOutput}; pub use bus::Bus; -pub use container::{AppContainer, AppDeps}; +pub use container::{AppContainer, AppDeps, KEYRING_SERVICE}; pub use dedup::{ComputeFullHashUseCase, DedupUseCase}; pub use events::EventHandler; pub use metadata::{MetadataCommand, MetadataOutput, MetadataUseCase}; @@ -53,4 +54,5 @@ pub use scan::{ pub use search::{SearchCommand, SearchOutput, SearchUseCase}; pub use tag::{FileWithTags, TagCommand, TagFilter, TagOutput, TagUseCase}; pub use telemetry::LogEventHandler; +pub use transcription::{TranscribeCommand, TranscribeOutput, TranscriptionUseCase}; pub use volume::{VolumeCommand, VolumeOutput, VolumeUseCase}; diff --git a/crates/app/src/metadata.rs b/crates/app/src/metadata.rs index b39753a..323c1a4 100644 --- a/crates/app/src/metadata.rs +++ b/crates/app/src/metadata.rs @@ -38,8 +38,7 @@ use std::sync::Arc; use perima_core::{ - CoreError, DeviceId, EventBus, FileLocationRecord, FileRepository, MediaMetadata, - MetadataRepository, + CoreError, DeviceId, EventBus, FileLocationRecord, FileRepository, MetadataRepository, }; /// Default page size when `limit` is `None`. @@ -98,7 +97,8 @@ pub enum MetadataOutput { Files(Vec), /// Response to [`MetadataCommand::ListFilesWithMetadata`] — each record - /// paired with its optional metadata and the raw `quick_hash` hex string. + /// paired with its optional metadata, the raw `quick_hash` hex string, + /// and the volume's current `mount_path` on this machine. /// /// `None` metadata means extraction is pending or failed; callers /// MUST NOT treat `None` as "file has no metadata" for display @@ -108,7 +108,13 @@ pub enum MetadataOutput { /// `None` if the backfill worker has not yet run. See /// [`MetadataRepository::list_with_metadata`] for the placeholder /// detection contract. - FilesWithMetadata(Vec<(FileLocationRecord, Option, Option)>), + /// + /// The fourth element is `volume_mounts.mount_path` for the file's + /// volume, or `None` when the volume is not currently mounted on this + /// machine. The desktop shell joins it with `relative_path` to + /// materialise an absolute path for ffmpeg / OS open-file actions + /// (T9 review fix). + FilesWithMetadata(Vec), } /// Orchestrator: file-location listing and metadata join. @@ -422,11 +428,11 @@ mod tests { // Find each row by path. let with_meta = rows .iter() - .find(|(r, _, _)| r.relative_path.as_str() == "media/has_meta.jpg") + .find(|(r, _, _, _)| r.relative_path.as_str() == "media/has_meta.jpg") .expect("file with metadata should appear"); let without_meta = rows .iter() - .find(|(r, _, _)| r.relative_path.as_str() == "media/no_meta.jpg") + .find(|(r, _, _, _)| r.relative_path.as_str() == "media/no_meta.jpg") .expect("file without metadata should appear"); assert!( diff --git a/crates/app/src/tag.rs b/crates/app/src/tag.rs index 14222e3..edfb767 100644 --- a/crates/app/src/tag.rs +++ b/crates/app/src/tag.rs @@ -63,6 +63,12 @@ pub struct FileWithTags { /// placeholder rows (`hash == quick_hash`). See /// [`MetadataRepository::list_with_metadata`] for the contract. pub quick_hash: Option, + /// Mount path of the file's volume on this machine, or `None` when + /// the volume is not currently mounted. Threaded through from + /// [`MetadataRepository::list_with_metadata`] so desktop can + /// materialise an absolute path for ffmpeg / OS open-file actions. + /// T9 review fix. + pub mount_path: Option, } /// Filter parameters for [`TagCommand::ListFilesWithTags`]. @@ -281,11 +287,11 @@ impl TagUseCase { // tags below. The desktop attach_tag_by_uuid command lets // pending files acquire tags by `file_uuid` regardless. let hashes: Vec = - rows.iter().filter_map(|(loc, _, _)| loc.hash).collect(); + rows.iter().filter_map(|(loc, _, _, _)| loc.hash).collect(); let tag_map = self.tags.tags_for_hashes(&hashes)?; let files = rows .into_iter() - .map(|(loc, meta, quick_hash)| { + .map(|(loc, meta, quick_hash, mount_path)| { let tags = loc.hash.map_or_else(Vec::new, |h| { tag_map.get(&h).cloned().unwrap_or_default() }); @@ -294,6 +300,7 @@ impl TagUseCase { metadata: meta, tags, quick_hash, + mount_path, } }) .collect(); diff --git a/crates/app/src/telemetry.rs b/crates/app/src/telemetry.rs index 18d4616..fad67f7 100644 --- a/crates/app/src/telemetry.rs +++ b/crates/app/src/telemetry.rs @@ -69,6 +69,57 @@ impl EventHandler for LogEventHandler { AppEvent::VerifyComplete { batch_id } => { tracing::info!(?batch_id, "verify complete"); } + AppEvent::TranscriptionStarted { + request_uuid, + file_uuid, + file_name, + queue_size, + } => { + tracing::info!( + %request_uuid, + %file_uuid, + %file_name, + queue_size, + "transcription started" + ); + } + AppEvent::TranscriptionProgress { + request_uuid, + processed_ms, + total_ms, + } => { + tracing::debug!( + %request_uuid, + processed_ms, + ?total_ms, + "transcription progress" + ); + } + AppEvent::TranscriptionCompleted { + request_uuid, + transcript_id, + file_uuid, + segment_count, + language, + } => { + tracing::info!( + %request_uuid, + %transcript_id, + %file_uuid, + segment_count, + ?language, + "transcription completed" + ); + } + AppEvent::TranscriptionCancelled { request_uuid } => { + tracing::info!(%request_uuid, "transcription cancelled"); + } + AppEvent::TranscriptionFailed { + request_uuid, + error, + } => { + tracing::warn!(%request_uuid, %error, "transcription failed"); + } } } } diff --git a/crates/app/src/transcription.rs b/crates/app/src/transcription.rs new file mode 100644 index 0000000..bab59d7 --- /dev/null +++ b/crates/app/src/transcription.rs @@ -0,0 +1,485 @@ +//! Transcription use-case: orchestrates provider selection, audio +//! extraction, transcription, and atomic persistence. Owns the +//! single-worker FIFO queue. +//! +//! See spec § `UseCase — crates/app::transcription`. +//! +//! # Concurrency model +//! +//! - One [`tokio::spawn`]ed worker task lives for the lifetime of the +//! use-case (and therefore the [`crate::AppContainer`]). +//! - Producer side: `execute(Start { .. })` builds an internal +//! `QueueItem` and pushes it through `flume::bounded(32)`. Queue full +//! → typed [`TranscriptionError::QueueFull`]. +//! - Worker side: pops FIFO, emits [`AppEvent::TranscriptionStarted`], +//! calls the active backend (sync — adapter bridges to async via +//! `block_in_place` + [`tokio::runtime::Handle::block_on`]), stamps +//! `UUIDv7` ids on segments, submits to the writer through +//! `SqliteTranscriptRepository`, and relies on the writer to emit +//! [`AppEvent::TranscriptionCompleted`] after `COMMIT`. Cancel / +//! failure paths emit [`AppEvent::TranscriptionCancelled`] / +//! [`AppEvent::TranscriptionFailed`] from the worker. +//! +//! WHY [`tokio::spawn`] inside [`TranscriptionUseCase::new`]: the use-case +//! is constructed by [`crate::AppContainer::new`], which both shells call +//! from inside an existing tokio runtime (Tauri's runtime in desktop; +//! `#[tokio::main]` in CLI). Tests constructing a use-case directly +//! MUST run on `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` +//! — `block_in_place` inside the cloud adapter requires the multi-thread +//! flavour. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use perima_core::CoreError; +use perima_core::events::{AppEvent, EventBus}; +use perima_core::transcription::{TranscribeRequest, TranscriptionError, TranscriptionProgress}; + +use perima_db::transcript_repo::{ + SqliteTranscriptRepository, TranscriptId, TranscriptRow, TranscriptSegmentRow, +}; +use perima_transcribe::registry::TranscriberRegistry; + +/// Bound on the in-flight + queued job count. +/// +/// Past this threshold the producer side returns +/// [`TranscriptionError::QueueFull`] so the UI can surface "queue is full" +/// as a typed error rather than a silent retry. +/// +/// WHY 32: Cloud transcription typically runs 2-10s per request, so 32 +/// buffers about 1-5 minutes of FIFO work without overwhelming a single +/// worker. The bound matches the spec's `flume::bounded(32)` figure. +pub const QUEUE_DEPTH: usize = 32; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +/// Caller commands for the use-case. +#[derive(Debug, Clone)] +pub enum TranscribeCommand { + /// Start a new transcription job. The use-case mints a fresh + /// `request_uuid` (`UUIDv7`) and returns it via + /// [`TranscribeOutput::Started`]. + Start { + /// File UUID (immutable surrogate per V011). + file_uuid: String, + /// Display name for UI surfacing (typically the file basename). + file_name: String, + /// Absolute path to the source media file. + source: PathBuf, + /// Optional language hint (BCP-47 short code, e.g. `"en"`). + language_hint: Option, + }, + /// Cancel an in-flight or queued job by `request_uuid`. + /// Idempotent — cancelling an unknown id is a no-op. + Cancel { + /// Request UUID returned by [`TranscribeOutput::Started`]. + request_uuid: String, + }, +} + +/// Use-case output. +#[derive(Debug, Clone)] +pub enum TranscribeOutput { + /// Job enqueued. `queue_position` is `1` when this is the only job. + Started { + /// Per-request `UUIDv7` (lowercase-hex simple form). Pairs with + /// every `AppEvent::Transcription*` variant for this job. + request_uuid: String, + /// 1-based position in the queue at the moment of enqueue. + queue_position: u32, + }, + /// Cancel acknowledged (token fired). The job may still be mid- + /// adapter-call — the worker observes the token and emits + /// [`AppEvent::TranscriptionCancelled`] on the next checkpoint. + Cancelled { + /// Per-request `UUIDv7` echoed back from the request. + request_uuid: String, + }, +} + +// --------------------------------------------------------------------------- +// Internal queue item +// --------------------------------------------------------------------------- + +/// One unit of work for the worker task. +#[derive(Debug)] +struct QueueItem { + request_uuid: String, + file_uuid: String, + file_name: String, + source: PathBuf, + language_hint: Option, + cancel: CancellationToken, +} + +// --------------------------------------------------------------------------- +// The use-case +// --------------------------------------------------------------------------- + +/// Orchestrates the transcription pipeline. One per process; lives inside +/// [`crate::AppContainer`]. +pub struct TranscriptionUseCase { + queue: flume::Sender, + cancels: Arc>>, +} + +impl std::fmt::Debug for TranscriptionUseCase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY manual Debug: the `flume::Sender` and the cancel + // map are runtime objects with no useful textual representation. + // The trait derive on the surrounding `AppContainer` needs us to + // be Debug, so emit a stable type name. + f.debug_struct("TranscriptionUseCase") + .field("queue_capacity", &QUEUE_DEPTH) + .finish_non_exhaustive() + } +} + +impl TranscriptionUseCase { + /// Construct + spawn the worker. The worker holds its own `Arc`s to + /// the registry, repo, and event bus. + /// + /// # Panics + /// + /// Must be called from within a tokio runtime context — `tokio::spawn` + /// requires it. Both shells (CLI `#[tokio::main]`, Desktop via + /// Tauri's runtime) satisfy this. + #[must_use] + pub fn new( + registry: Arc, + repo: Arc, + events: Arc, + device: String, + ) -> Self { + let (tx, rx) = flume::bounded(QUEUE_DEPTH); + let cancels: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + // Worker owns its own Arc clones — the use-case keeps the + // sender + the cancel map for the producer-side `execute` calls. + tokio::spawn(worker_loop( + rx, + registry, + repo, + events, + Arc::clone(&cancels), + device, + )); + + Self { queue: tx, cancels } + } + + /// Execute a [`TranscribeCommand`]. + /// + /// `Start`: returns immediately with the new `request_uuid` and 1-based + /// queue position. The worker picks up the job asynchronously and + /// publishes `AppEvent::TranscriptionStarted` when it begins. + /// + /// `Cancel`: fires the cancel token. The cancel signal is checked at + /// adapter checkpoints (per-segment + HTTP poll) AND inside the + /// writer's transaction (post-`BEGIN IMMEDIATE`, pre-INSERT). Cancel + /// for an unknown id is a no-op (idempotent). + /// + /// # Errors + /// + /// Returns [`CoreError::Transcription`] wrapping + /// [`TranscriptionError::QueueFull`] when the bounded queue is at + /// capacity. Surface this to the user as "transcription queue full; + /// try again later" — never silently retry. + /// + /// # Panics + /// + /// Panics if the internal `cancels` mutex is poisoned, which only + /// happens if a previous holder panicked while holding the lock — + /// treated as an unrecoverable bug rather than papered over. + #[allow(clippy::cast_possible_truncation)] + // WHY: queue.len() is bounded above by QUEUE_DEPTH (32) so the + // usize -> u32 cast is loss-free. + #[allow(clippy::unused_async)] + // WHY allow unused_async: matches the established pattern across every + // other UseCase::execute (search, tag, scan, dedup, …). Keeping the + // signature async preserves forward-compat with adapters that may move + // off `SqliteWriter::send` (sync) onto async write paths in a later + // batch — callers don't need to migrate then. + pub async fn execute(&self, cmd: TranscribeCommand) -> Result { + match cmd { + TranscribeCommand::Start { + file_uuid, + file_name, + source, + language_hint, + } => { + let request_uuid = Uuid::now_v7().simple().to_string(); + let cancel = CancellationToken::new(); + self.cancels + .lock() + .expect("transcription cancels mutex poisoned") + .insert(request_uuid.clone(), cancel.clone()); + + let queued = self.queue.len() as u32; + self.queue + .try_send(QueueItem { + request_uuid: request_uuid.clone(), + file_uuid, + file_name, + source, + language_hint, + cancel, + }) + .map_err(|_| { + // WHY remove the cancel entry on QueueFull: the job + // never enters the worker, so a future Cancel call + // for the same id would silently fail without this + // cleanup. We also avoid retaining dead entries. + let _ = self + .cancels + .lock() + .expect("transcription cancels mutex poisoned") + .remove(&request_uuid); + CoreError::Transcription(TranscriptionError::QueueFull { queued }) + })?; + Ok(TranscribeOutput::Started { + request_uuid, + queue_position: queued.saturating_add(1), + }) + } + TranscribeCommand::Cancel { request_uuid } => { + // WHY pull the token out of the mutex before the if-let: + // `clippy::significant_drop_in_scrutinee` flags holding the + // MutexGuard temporary across the if-let body. Binding the + // Option here drops the guard before token.cancel() runs. + let token = self + .cancels + .lock() + .expect("transcription cancels mutex poisoned") + .remove(&request_uuid); + if let Some(token) = token { + token.cancel(); + } + Ok(TranscribeOutput::Cancelled { request_uuid }) + } + } + } +} + +// --------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------- + +async fn worker_loop( + rx: flume::Receiver, + registry: Arc, + repo: Arc, + events: Arc, + cancels: Arc>>, + device: String, +) { + while let Ok(item) = rx.recv_async().await { + // Snapshot remaining queue depth at dequeue + this job = live queue + // size as observed by the worker. WHY: AppEvent::TranscriptionStarted. + // queue_size docstring promises "current queue size including this + // job"; rx.len() is jobs still waiting behind this one (post-recv). + #[allow(clippy::cast_possible_truncation)] + let queue_size = (rx.len() as u32).saturating_add(1); + process_one(&item, queue_size, ®istry, &repo, &events, &device); + // Always remove from cancels map after terminal state so a stale + // Cancel for a completed id is a true no-op (instead of firing a + // dead token uselessly). + let _ = cancels + .lock() + .expect("transcription cancels mutex poisoned") + .remove(&item.request_uuid); + } + tracing::debug!("transcription worker loop exiting (queue closed)"); +} + +/// Process a single queued item end-to-end (start event → backend call → +/// persist → terminal event). +/// +/// WHY a free function rather than a method: the worker only borrows +/// `Arc` clones, never `&self` of the use-case (the use-case is dropped +/// at container shutdown but the worker task may still be draining). +#[allow(clippy::cognitive_complexity)] // WHY: the linear pipeline reads top-to-bottom; splitting harms readability. +fn process_one( + item: &QueueItem, + queue_size: u32, + registry: &Arc, + repo: &Arc, + events: &Arc, + device: &str, +) { + // 1. Emit the started event. Build first to dodge the + // "&AppEvent::Variant{...}" temporary-lifetime trap (spec § + // "UseCase"). + let started_event = AppEvent::TranscriptionStarted { + request_uuid: item.request_uuid.clone(), + file_uuid: item.file_uuid.clone(), + file_name: item.file_name.clone(), + queue_size, + }; + if let Err(e) = events.emit(&started_event) { + tracing::warn!(error = %e, "failed to emit TranscriptionStarted"); + } + + // 2. Resolve the active backend. + let backend = match registry.active() { + Ok(b) => b, + Err(e) => { + emit_failure(events, &item.request_uuid, &e); + return; + } + }; + + // 3. Build the request, threading the cancel token end-to-end. + let request_uuid_for_progress = item.request_uuid.clone(); + let events_for_progress: Arc = Arc::clone(events); + let on_progress: Arc = Arc::new(move |p| { + // WHY: only Segment + Heartbeat surface as progress events; the + // Started/Finished progress signals are absorbed by the worker + // (it emits its own start/terminal AppEvents). + let event = match p { + TranscriptionProgress::Segment { + processed_ms, + total_ms, + .. + } => AppEvent::TranscriptionProgress { + request_uuid: request_uuid_for_progress.clone(), + processed_ms, + total_ms, + }, + TranscriptionProgress::Heartbeat { elapsed } => { + let processed_ms = u32::try_from(elapsed.as_millis()).unwrap_or(u32::MAX); + AppEvent::TranscriptionProgress { + request_uuid: request_uuid_for_progress.clone(), + processed_ms, + total_ms: None, + } + } + TranscriptionProgress::Started { .. } | TranscriptionProgress::Finished => return, + }; + if let Err(e) = events_for_progress.emit(&event) { + tracing::warn!(error = %e, "failed to emit TranscriptionProgress"); + } + }); + + let req = TranscribeRequest { + source: item.source.clone(), + language_hint: item.language_hint.clone(), + cancel: item.cancel.clone(), + on_progress, + timeout: None, + }; + + // 4. Run the (sync) adapter call. + let result = backend.transcribe(&req); + + let mut transcription = match result { + Ok(t) => t, + Err(e) => { + emit_terminal_for_error(events, &item.request_uuid, &e); + return; + } + }; + + // 5a. Stamp UUIDv7 ids on segments. The adapter sets nil-UUIDs as + // placeholders so the use-case can mint stable ids without + // introducing a UUID dep in the adapter crate's hot loop. + for seg in &mut transcription.segments { + if seg.id == Uuid::nil() { + seg.id = Uuid::now_v7(); + } + } + + // 5b. Pre-write cancel check. Narrows the cancel-after-success window. + // The writer also re-checks inside its transaction (post-BEGIN + // IMMEDIATE, pre-INSERT) — together they fully close the race. + if item.cancel.is_cancelled() { + emit_cancelled(events, &item.request_uuid); + return; + } + + // 6. Build rows and submit to the writer. + let transcript_row = TranscriptRow { + id: TranscriptId::new(), + file_uuid: item.file_uuid.clone(), + backend: backend.id().0.clone(), + language: transcription.language.clone(), + duration_ms: transcription.duration_ms, + }; + let segment_rows: Vec = transcription + .segments + .iter() + .map(|s| TranscriptSegmentRow { + id: TranscriptId(s.id.simple().to_string()), + // WHY empty: the writer overrides this with `transcript_row.id` + // before binding (matches the From contract). + transcript_id: TranscriptId(String::new()), + start_ms: s.start_ms, + end_ms: s.end_ms, + text: s.text.clone(), + confidence: s.confidence, + }) + .collect(); + + match repo.insert_with_request_uuid( + transcript_row, + segment_rows, + device.to_owned(), + Some(item.cancel.clone()), + item.request_uuid.clone(), + ) { + Ok(_) => { + // The writer emitted `AppEvent::TranscriptionCompleted` itself + // post-COMMIT (with the use-case's request_uuid threaded through + // the writer-cmd payload). Nothing to do here. + } + Err(e) => emit_terminal_for_error(events, &item.request_uuid, &e), + } +} + +// --------------------------------------------------------------------------- +// Terminal-event helpers +// --------------------------------------------------------------------------- + +fn emit_terminal_for_error(events: &Arc, request_uuid: &str, e: &CoreError) { + if matches!(e, CoreError::Transcription(TranscriptionError::Cancelled)) { + emit_cancelled(events, request_uuid); + } else { + emit_failure(events, request_uuid, e); + } +} + +fn emit_cancelled(events: &Arc, request_uuid: &str) { + let event = AppEvent::TranscriptionCancelled { + request_uuid: request_uuid.to_owned(), + }; + if let Err(e) = events.emit(&event) { + tracing::warn!(error = %e, "failed to emit TranscriptionCancelled"); + } +} + +fn emit_failure(events: &Arc, request_uuid: &str, e: &CoreError) { + let event = AppEvent::TranscriptionFailed { + request_uuid: request_uuid.to_owned(), + error: extract_transcription_error(e), + }; + if let Err(emit_err) = events.emit(&event) { + tracing::warn!(error = %emit_err, original_error = %e, "failed to emit TranscriptionFailed"); + } +} + +/// Lift a [`CoreError`] to a [`TranscriptionError`] for inclusion in the +/// `Failed` event. Non-transcription core errors are wrapped under +/// [`TranscriptionError::Internal`] so the frontend has one switch surface. +fn extract_transcription_error(e: &CoreError) -> TranscriptionError { + match e { + CoreError::Transcription(t) => t.clone(), + other => TranscriptionError::Internal(other.to_string()), + } +} diff --git a/crates/app/tests/transcription_test.rs b/crates/app/tests/transcription_test.rs new file mode 100644 index 0000000..a32d86f --- /dev/null +++ b/crates/app/tests/transcription_test.rs @@ -0,0 +1,512 @@ +//! Use-case tests for [`perima_app::TranscriptionUseCase`]. +//! +//! Strategy: build a `TranscriberRegistry` containing a `FakeTranscriber` +//! whose behaviour the test harness controls (Ok / Err / sleep + observe +//! cancel). All four scenarios from the plan are covered: +//! +//! - **Happy path** — Ok returned; events fire `Started` → (writer-emitted) +//! `Completed`; rows persist. +//! - **Cancel mid-flight** — adapter sleeps; test fires the cancel token; +//! adapter returns `Cancelled`; `TranscriptionCancelled` is observed. +//! - **Queue overflow** — fire `QUEUE_DEPTH + 1` jobs; the (n+1)-th +//! `Start` returns typed `QueueFull`. +//! - **Error mapping** — adapter returns `Auth`; `TranscriptionFailed` +//! carries the same discriminant. +//! +//! The use-case spawns its worker with `tokio::spawn`, so every test runs on +//! `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` per the +//! plan's standing constraint (`block_in_place` requires multi-thread). + +#![allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tempfile::TempDir; +use tokio::time::timeout; + +use perima_app::{TranscribeCommand, TranscribeOutput, TranscriptionUseCase, transcription}; +use perima_core::transcription::{ + BackendId, TranscribeRequest, Transcriber, TranscriptSegment, TranscriptionError, + TranscriptionResult, +}; +use perima_core::{AppEvent, CoreError, EventBus}; +use perima_db::transcript_repo::SqliteTranscriptRepository; +use perima_db::{ReadPool, SqliteWriter, SqliteWriterHandle}; +use perima_transcribe::registry::TranscriberRegistry; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +/// Records every emitted [`AppEvent`] in insertion order. +#[derive(Default)] +struct RecordingBus { + events: Mutex>, +} + +impl EventBus for RecordingBus { + fn emit(&self, e: &AppEvent) -> Result<(), CoreError> { + self.events.lock().unwrap().push(e.clone()); + Ok(()) + } +} + +impl RecordingBus { + fn snapshot(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +/// Behaviour the [`FakeTranscriber`] follows on each `transcribe` call. +#[derive(Clone)] +enum Behaviour { + /// Return a single-segment success result immediately. + Ok, + /// Return the given error. + Err(TranscriptionError), + /// Sleep up to `max` checking `cancel.is_cancelled()` every 25 ms; + /// if cancel fires, return `Cancelled`. If the sleep finishes + /// without cancel, return Ok (the test should not let this happen). + SleepThenCancel { max: Duration }, +} + +/// Stub Transcriber whose response is fixed at construction time. +struct FakeTranscriber { + id: BackendId, + behaviour: Behaviour, +} + +impl FakeTranscriber { + fn new(provider: &str, behaviour: Behaviour) -> Self { + Self { + id: BackendId(format!("{provider}:fake-model")), + behaviour, + } + } +} + +impl Transcriber for FakeTranscriber { + fn id(&self) -> &BackendId { + &self.id + } + + fn accepts(&self, _mime: &str) -> bool { + true + } + + fn transcribe(&self, req: &TranscribeRequest) -> Result { + match &self.behaviour { + Behaviour::Ok => Ok(TranscriptionResult { + language: Some("en".to_owned()), + duration_ms: 1_500, + segments: vec![TranscriptSegment { + // Nil; the use-case stamps a real UUIDv7. + id: Uuid::nil(), + start_ms: 0, + end_ms: 1_500, + text: "hello world".to_owned(), + confidence: Some(0.95), + }], + backend: self.id.clone(), + }), + Behaviour::Err(e) => Err(CoreError::Transcription(e.clone())), + Behaviour::SleepThenCancel { max } => { + let deadline = std::time::Instant::now() + *max; + while std::time::Instant::now() < deadline { + if req.cancel.is_cancelled() { + return Err(CoreError::Transcription(TranscriptionError::Cancelled)); + } + std::thread::sleep(Duration::from_millis(25)); + } + // Should not happen under the cancel test (max is 5s, the + // test fires cancel within 100 ms). + Ok(TranscriptionResult { + language: None, + duration_ms: 0, + segments: vec![], + backend: self.id.clone(), + }) + } + } + } +} + +/// Build a fresh on-disk DB + writer + transcript repo. The writer handle +/// is returned so the test can keep it alive past the use-case construction. +/// +/// `writer_bus` is also handed to `SqliteWriter::start` so post-COMMIT +/// `TranscriptionCompleted` events surface on it (every test passes the +/// same bus it later inspects). +fn db_harness_with_bus( + writer_bus: Arc, +) -> (TempDir, SqliteWriterHandle, Arc) { + let td = tempfile::tempdir().unwrap(); + let db_path = td.path().join("perima.db"); + let writer = SqliteWriter::start(&db_path, writer_bus).unwrap(); + let reads = ReadPool::open(&db_path).unwrap(); + let repo = Arc::new(SqliteTranscriptRepository::new(writer.sender(), reads)); + (td, writer, repo) +} + +/// Local `EventBus` that drops every event. Mirrors +/// `perima_db::test_utils::NoopBus` (which is gated behind the +/// `test-utils` feature, not enabled in `perima-app`). +struct LocalNoopBus; + +impl EventBus for LocalNoopBus { + fn emit(&self, _: &AppEvent) -> Result<(), CoreError> { + Ok(()) + } +} + +/// Convenience: writer-bus is a fresh no-op bus (drops all events). +fn db_harness() -> (TempDir, SqliteWriterHandle, Arc) { + db_harness_with_bus(Arc::new(LocalNoopBus)) +} + +/// Build a registry pre-loaded with one fake under `provider:fake-model`, +/// set as active. +fn registry_with_fake(provider: &str, behaviour: Behaviour) -> Arc { + let mut registry = TranscriberRegistry::new(); + let fake = Arc::new(FakeTranscriber::new(provider, behaviour)); + let id = fake.id().clone(); + registry.register(fake); + registry.set_active(id).unwrap(); + Arc::new(registry) +} + +/// Wait up to `timeout_ms` for the recording bus to surface an event matching +/// `pred`. Returns the matched event (cloned). +async fn wait_for_event(bus: &RecordingBus, pred: F, timeout_ms: u64) -> Option +where + F: Fn(&AppEvent) -> bool, +{ + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + while std::time::Instant::now() < deadline { + if let Some(e) = bus.snapshot().into_iter().find(|e| pred(e)) { + return Some(e); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + None +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// Happy path: Start a job; the worker emits `TranscriptionStarted`, the +/// writer emits `TranscriptionCompleted` after persistence. +/// +/// WHY pass `bus` to BOTH the writer AND the use-case: the +/// `TranscriptionCompleted` event is emitted from inside the writer thread +/// post-COMMIT (carries the use-case's `request_uuid` threaded via +/// `WriteCmd::Transcript::Insert::request_uuid`). To observe both +/// `TranscriptionStarted` (worker emit) and `TranscriptionCompleted` +/// (writer emit) on a single recording surface, both sites use the same +/// `RecordingBus`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn happy_path_emits_started_then_completed() { + let bus: Arc = Arc::new(RecordingBus::default()); + let (_td, _writer, repo) = db_harness_with_bus(Arc::clone(&bus) as Arc); + let registry = registry_with_fake("groq", Behaviour::Ok); + + let use_case = TranscriptionUseCase::new( + registry, + repo, + Arc::clone(&bus) as Arc, + "test-device-001".to_owned(), + ); + + let out = use_case + .execute(TranscribeCommand::Start { + file_uuid: Uuid::now_v7().simple().to_string(), + file_name: "happy.m4a".to_owned(), + source: std::path::PathBuf::from("/dev/null"), + language_hint: None, + }) + .await + .unwrap(); + let request_uuid = match out { + TranscribeOutput::Started { + request_uuid, + queue_position, + } => { + assert_eq!(queue_position, 1, "first job queue_position should be 1"); + request_uuid + } + other @ TranscribeOutput::Cancelled { .. } => { + panic!("expected Started, got {other:?}") + } + }; + + let started = wait_for_event( + &bus, + |e| matches!(e, AppEvent::TranscriptionStarted { request_uuid: r, .. } if r == &request_uuid), + 2_000, + ) + .await; + assert!(started.is_some(), "TranscriptionStarted not observed"); + + let completed = wait_for_event( + &bus, + |e| matches!(e, AppEvent::TranscriptionCompleted { request_uuid: r, .. } if r == &request_uuid), + 5_000, + ) + .await; + let Some(AppEvent::TranscriptionCompleted { + segment_count, + language, + .. + }) = completed + else { + panic!( + "TranscriptionCompleted not observed; bus saw {:?}", + bus.snapshot() + ); + }; + assert_eq!(segment_count, 1, "expected 1 segment"); + assert_eq!(language.as_deref(), Some("en")); +} + +/// Cancel mid-flight: the adapter sleeps observing the cancel token; the +/// test fires Cancel; the adapter returns Cancelled; the worker emits +/// `TranscriptionCancelled`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_mid_flight_emits_transcription_cancelled() { + let (_td, _writer, repo) = db_harness(); + let bus: Arc = Arc::new(RecordingBus::default()); + let registry = registry_with_fake( + "groq", + Behaviour::SleepThenCancel { + max: Duration::from_secs(5), + }, + ); + + let use_case = TranscriptionUseCase::new( + registry, + repo, + Arc::clone(&bus) as Arc, + "test-device-002".to_owned(), + ); + + let out = use_case + .execute(TranscribeCommand::Start { + file_uuid: Uuid::now_v7().simple().to_string(), + file_name: "cancel.m4a".to_owned(), + source: std::path::PathBuf::from("/dev/null"), + language_hint: None, + }) + .await + .unwrap(); + let request_uuid = match out { + TranscribeOutput::Started { request_uuid, .. } => request_uuid, + other @ TranscribeOutput::Cancelled { .. } => { + panic!("expected Started, got {other:?}") + } + }; + + // Wait for the worker to emit Started before cancelling so we know it + // entered the adapter. + wait_for_event( + &bus, + |e| matches!(e, AppEvent::TranscriptionStarted { request_uuid: r, .. } if r == &request_uuid), + 2_000, + ) + .await + .expect("Started event should fire"); + + // Fire the cancel. + use_case + .execute(TranscribeCommand::Cancel { + request_uuid: request_uuid.clone(), + }) + .await + .unwrap(); + + let cancelled = wait_for_event( + &bus, + |e| matches!(e, AppEvent::TranscriptionCancelled { request_uuid: r } if r == &request_uuid), + 5_000, + ) + .await; + assert!( + cancelled.is_some(), + "TranscriptionCancelled not observed; bus saw {:?}", + bus.snapshot() + ); +} + +/// Queue overflow: enqueue `QUEUE_DEPTH + 1` jobs as fast as possible. The +/// (n+1)-th `Start` must return `Err(Transcription(QueueFull))`. +/// +/// Strategy: use a fake transcriber that BLOCKS the worker by holding it +/// in a sync wait until a test-controlled `Arc` flips. This +/// keeps the worker pinned long enough for the producer to fill the +/// queue without sleeping for an arbitrary wall-clock duration. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn queue_overflow_returns_queue_full() { + use std::sync::atomic::{AtomicBool, Ordering}; + + /// Block until `release` flips to true, observing `cancel`. + struct BlockingTranscriber { + id: BackendId, + release: Arc, + } + + impl Transcriber for BlockingTranscriber { + fn id(&self) -> &BackendId { + &self.id + } + fn accepts(&self, _: &str) -> bool { + true + } + fn transcribe(&self, req: &TranscribeRequest) -> Result { + while !self.release.load(Ordering::SeqCst) { + if req.cancel.is_cancelled() { + return Err(CoreError::Transcription(TranscriptionError::Cancelled)); + } + std::thread::sleep(Duration::from_millis(5)); + } + Ok(TranscriptionResult { + language: None, + duration_ms: 0, + segments: vec![], + backend: self.id.clone(), + }) + } + } + + let (_td, _writer, repo) = db_harness(); + let bus: Arc = Arc::new(RecordingBus::default()); + let release = Arc::new(AtomicBool::new(false)); + let mut registry = TranscriberRegistry::new(); + let backend: Arc = Arc::new(BlockingTranscriber { + id: BackendId("groq:fake-model".to_owned()), + release: Arc::clone(&release), + }); + let id = backend.id().clone(); + registry.register(backend); + registry.set_active(id).unwrap(); + + let use_case = TranscriptionUseCase::new( + Arc::new(registry), + repo, + Arc::clone(&bus) as Arc, + "test-device-003".to_owned(), + ); + + // Producer-side: hammer Start until QueueFull surfaces. With one job + // in-flight (worker blocked) + QUEUE_DEPTH queued, the + // QUEUE_DEPTH + 2 attempt must trip QueueFull. + let mut queue_full_seen = false; + let mut last_queued = 0; + for i in 0..(transcription::QUEUE_DEPTH + 4) { + let r = use_case + .execute(TranscribeCommand::Start { + file_uuid: Uuid::now_v7().simple().to_string(), + file_name: format!("job-{i}.m4a"), + source: std::path::PathBuf::from("/dev/null"), + language_hint: None, + }) + .await; + match r { + Ok(_) => { + last_queued += 1; + } + Err(CoreError::Transcription(TranscriptionError::QueueFull { .. })) => { + queue_full_seen = true; + break; + } + Err(other) => panic!("unexpected error from Start: {other:?}"), + } + } + assert!( + queue_full_seen, + "QueueFull never surfaced; only {last_queued} jobs accepted (QUEUE_DEPTH={})", + transcription::QUEUE_DEPTH + ); + + // Cleanup: release the blocking worker so test shutdown is fast. + release.store(true, Ordering::SeqCst); + // Give the worker a beat to drain any remaining queued items. + tokio::time::sleep(Duration::from_millis(100)).await; + drop(use_case); +} + +/// Error mapping: the adapter returns `Auth`; the worker emits +/// `TranscriptionFailed { error: Auth }`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auth_error_emits_transcription_failed_with_same_discriminant() { + let (_td, _writer, repo) = db_harness(); + let bus: Arc = Arc::new(RecordingBus::default()); + let registry = registry_with_fake("groq", Behaviour::Err(TranscriptionError::Auth)); + + let use_case = TranscriptionUseCase::new( + registry, + repo, + Arc::clone(&bus) as Arc, + "test-device-004".to_owned(), + ); + + let out = use_case + .execute(TranscribeCommand::Start { + file_uuid: Uuid::now_v7().simple().to_string(), + file_name: "failing.m4a".to_owned(), + source: std::path::PathBuf::from("/dev/null"), + language_hint: None, + }) + .await + .unwrap(); + let request_uuid = match out { + TranscribeOutput::Started { request_uuid, .. } => request_uuid, + other @ TranscribeOutput::Cancelled { .. } => { + panic!("expected Started, got {other:?}") + } + }; + + let failed = wait_for_event( + &bus, + |e| matches!(e, AppEvent::TranscriptionFailed { request_uuid: r, .. } if r == &request_uuid), + 5_000, + ) + .await; + let Some(AppEvent::TranscriptionFailed { error, .. }) = failed else { + panic!( + "TranscriptionFailed not observed; bus saw {:?}", + bus.snapshot() + ); + }; + assert!( + matches!(error, TranscriptionError::Auth), + "expected Auth, got {error:?}" + ); +} + +/// Smoke test: cancelling an unknown `request_uuid` is idempotent. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_unknown_request_uuid_is_idempotent() { + let (_td, _writer, repo) = db_harness(); + let bus: Arc = Arc::new(RecordingBus::default()); + let registry = registry_with_fake("groq", Behaviour::Ok); + + let use_case = TranscriptionUseCase::new( + registry, + repo, + Arc::clone(&bus) as Arc, + "test-device-005".to_owned(), + ); + + let out = timeout( + Duration::from_secs(2), + use_case.execute(TranscribeCommand::Cancel { + request_uuid: "deadbeef".to_owned(), + }), + ) + .await + .unwrap() + .unwrap(); + assert!(matches!(out, TranscribeOutput::Cancelled { .. })); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ee5c798..a9486fd 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,6 +20,10 @@ perima-media = { workspace = true } # WHY workspace = true: release-plz propagates Batch-B app-layer bumps # into the CLI, same rationale as perima-media above. perima-app = { workspace = true } +# WHY perima-transcribe in CLI: build_container needs to construct a +# CliFfmpegInvoker + FfmpegAudioPipeline at startup so the container's +# transcription use-case can run without a Tauri sidecar resolver. +perima-transcribe = { path = "../transcribe" } chrono.workspace = true serde_json.workspace = true serde.workspace = true @@ -34,6 +38,32 @@ rayon.workspace = true uuid.workspace = true tokio.workspace = true tokio-util.workspace = true +# WHY tempfile: MissingFfmpegPipeline (in main.rs) returns NamedTempFile in +# its trait signature. Pulled into runtime deps; was previously dev-only. +tempfile.workspace = true +# WHY dialoguer: hidden-input password prompt for `perima auth set `. +# The non-TTY fallback (read from stdin) bypasses dialoguer for CI / piped input. +dialoguer.workspace = true +# WHY keyring: `perima auth {set,delete,has,list}` manage entries under the +# canonical `perima.transcription` service. Same crate the container reads +# from at startup. +keyring.workspace = true +# WHY regex: clap value_parser for BCP-47 `--language` shape validation +# (loose RFC 5646 subset; promote to `unic-langid` for full handling). +regex.workspace = true +# WHY async-broadcast: dispatch_transcribe subscribes to AppContainer.events +# (a `Bus` backed by async-broadcast) and awaits the terminal Transcription* +# event matching the request UUID. +async-broadcast = { workspace = true } +# WHY flume in runtime deps: dispatch_transcribe uses a bounded channel to +# funnel terminal AppEvent::Transcription* events from the EventHandler task +# back into the dispatcher's await. +flume = { workspace = true } +# WHY rusqlite (read-only) in runtime deps: cmd::transcribe loads the freshly +# persisted transcript text via a short-lived read-only connection. The T7 +# read-API has not landed yet; this is the v0.6.x stop-gap and will be +# replaced when the transcript repo grows a `list_for_transcript` method. +rusqlite = { workspace = true } [dev-dependencies] tempfile.workspace = true @@ -46,6 +76,11 @@ predicates.workspace = true # synthesises PNG/JPEG fixtures at runtime (no binary blobs in git), # matching the pattern already used in `crates/media/tests`. image.workspace = true +# WHY hound + wiremock in dev-deps: `transcribe_test.rs` synthesises a tiny +# WAV via hound and stubs the OpenAI-compat HTTP endpoint via wiremock to +# drive the `transcribe` subcommand end-to-end. +hound.workspace = true +wiremock.workspace = true [target.'cfg(unix)'.dev-dependencies] # WHY: nix provides POSIX signal utilities (kill/SIGTERM) without unsafe libc. diff --git a/crates/cli/src/cmd/auth.rs b/crates/cli/src/cmd/auth.rs new file mode 100644 index 0000000..caa3972 --- /dev/null +++ b/crates/cli/src/cmd/auth.rs @@ -0,0 +1,186 @@ +//! `perima auth` subcommand — manage transcription-provider keyring entries. +//! +//! Service: `"perima.transcription"` (matches the constant the +//! `AppContainer` reads from at startup; spec §"Settings + auth"). +//! +//! Sub-actions: +//! - `set ` — prompt for password (blind input, no echo); +//! stores under service `perima.transcription`, account ``. +//! Reads from stdin when stdin is non-TTY (CI / piped input). +//! - `delete ` — remove the keyring entry; idempotent (no error +//! when the entry never existed). +//! - `has ` — exit 0 if entry exists, 1 if not. +//! - `list` — prints active provider + every provider with a +//! keyring entry; one per line. + +use std::io::{IsTerminal, Read, Write}; +use std::path::Path; + +use clap::{Args, Subcommand}; +use perima_app::KEYRING_SERVICE; +use perima_app::config::transcription::TranscriptionConfig; +use perima_core::CoreError; + +/// Arguments for `perima auth`. +#[derive(Args, Debug)] +pub(crate) struct AuthArgs { + /// The auth sub-action to perform. + #[command(subcommand)] + pub action: AuthAction, +} + +/// Individual actions under `perima auth`. +#[derive(Subcommand, Debug)] +pub(crate) enum AuthAction { + /// Store an API key under the given provider name. Prompts for the + /// key with hidden input on a TTY; reads from stdin otherwise. + Set { + /// Provider name as it appears in `[transcription.providers.*]`. + provider: String, + }, + /// Remove the keyring entry for the given provider. Idempotent. + Delete { + /// Provider name to remove. + provider: String, + }, + /// Exit 0 if a keyring entry exists for the given provider, 1 if not. + Has { + /// Provider name to check. + provider: String, + }, + /// Print the active provider and every provider with a stored key. + List, +} + +fn keyring_entry(provider: &str) -> Result { + keyring::Entry::new(KEYRING_SERVICE, provider) + .map_err(|e| CoreError::Internal(format!("keyring entry: {e}"))) +} + +fn read_password_blind(provider: &str) -> Result { + if std::io::stdin().is_terminal() { + // WHY dialoguer's Password widget on TTY: hides typed bytes from + // shoulder-surfing observers and disables echo on Unix terminals. + // The non-TTY fallback below is for piped input (CI, automation). + dialoguer::Password::new() + .with_prompt(format!("API key for provider {provider}")) + .interact() + .map_err(|e| CoreError::Internal(format!("dialoguer: {e}"))) + } else { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(CoreError::from)?; + // Strip a single trailing \n if present; preserve any other whitespace. + let trimmed = buf + .strip_suffix("\r\n") + .or_else(|| buf.strip_suffix('\n')) + .unwrap_or(&buf) + .to_owned(); + if trimmed.is_empty() { + return Err(CoreError::Internal( + "empty password (read from non-TTY stdin)".into(), + )); + } + Ok(trimmed) + } +} + +/// Run `perima auth `. +/// +/// # Errors +/// Returns [`CoreError::Internal`] on keyring or I/O failures. +pub(crate) fn run(args: &AuthArgs, config_dir: &Path) -> Result { + match &args.action { + AuthAction::Set { provider } => run_set(provider), + AuthAction::Delete { provider } => run_delete(provider), + AuthAction::Has { provider } => run_has(provider), + AuthAction::List => run_list(config_dir), + } +} + +fn run_set(provider: &str) -> Result { + let password = read_password_blind(provider)?; + let entry = keyring_entry(provider)?; + entry + .set_password(&password) + .map_err(|e| CoreError::Internal(format!("keyring set: {e}")))?; + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "stored API key for provider {provider}"); + Ok(0) +} + +fn run_delete(provider: &str) -> Result { + let entry = keyring_entry(provider)?; + match entry.delete_credential() { + Ok(()) => { + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "deleted keyring entry for {provider}"); + Ok(0) + } + Err(keyring::Error::NoEntry) => { + // Idempotent — nothing to delete is success. + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "no keyring entry for {provider} (already absent)"); + Ok(0) + } + Err(e) => Err(CoreError::Internal(format!("keyring delete: {e}"))), + } +} + +fn run_has(provider: &str) -> Result { + let entry = keyring_entry(provider)?; + match entry.get_password() { + Ok(_) => Ok(0), + Err(keyring::Error::NoEntry) => Ok(1), + Err(e) => Err(CoreError::Internal(format!("keyring get: {e}"))), + } +} + +fn run_list(config_dir: &Path) -> Result { + let cfg = TranscriptionConfig::load(config_dir)?; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + + let active_label = cfg.active_provider.as_deref().unwrap_or("(none)"); + writeln!(handle, "active provider: {active_label}").map_err(CoreError::from)?; + + if cfg.providers.is_empty() { + writeln!(handle, "no providers configured (edit config.toml)").map_err(CoreError::from)?; + return Ok(0); + } + + writeln!(handle, "providers:").map_err(CoreError::from)?; + // Sort for deterministic output. + let mut names: Vec<&String> = cfg.providers.keys().collect(); + names.sort(); + for name in names { + let mark = match keyring_entry(name).and_then(|e| { + e.get_password() + .map_err(|err| CoreError::Internal(format!("keyring: {err}"))) + }) { + Ok(_) => "[has key]", + Err(_) => "[no key] ", + }; + let active_mark = if cfg.active_provider.as_deref() == Some(name.as_str()) { + "*" + } else { + " " + }; + writeln!(handle, " {active_mark} {mark} {name}").map_err(CoreError::from)?; + } + Ok(0) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn keyring_service_name_matches_app_const() { + // PINNED: cli auth + app container MUST use the same service + // string so a credential set by one is visible to the other. + assert_eq!(KEYRING_SERVICE, "perima.transcription"); + } +} diff --git a/crates/cli/src/cmd/ls.rs b/crates/cli/src/cmd/ls.rs index 610f6ff..cd25b10 100644 --- a/crates/cli/src/cmd/ls.rs +++ b/crates/cli/src/cmd/ls.rs @@ -13,9 +13,7 @@ use std::collections::HashSet; use std::io::Write; use perima_app::{AppContainer, MetadataCommand, MetadataOutput}; -use perima_core::{ - BlakeHash, CoreError, DeviceId, FileLocationRecord, MediaMetadata, VolumeId, normalize_tag, -}; +use perima_core::{BlakeHash, CoreError, DeviceId, FileLocationRecord, VolumeId, normalize_tag}; /// Arguments for the ls command. #[derive(Debug, Clone)] @@ -140,14 +138,14 @@ fn apply_volume_filter( } fn apply_metadata_volume_filter( - rows: Vec<(FileLocationRecord, Option, Option)>, + rows: Vec, volume: Option, -) -> Vec<(FileLocationRecord, Option, Option)> { +) -> Vec { match volume { None => rows, Some(v) => rows .into_iter() - .filter(|(r, _, _)| r.volume_id == v) + .filter(|(r, _, _, _)| r.volume_id == v) .collect(), } } @@ -169,14 +167,14 @@ fn apply_tag_filter( } fn apply_metadata_tag_filter( - rows: Vec<(FileLocationRecord, Option, Option)>, + rows: Vec, filter: Option<&HashSet>, -) -> Vec<(FileLocationRecord, Option, Option)> { +) -> Vec { match filter { None => rows, Some(set) => rows .into_iter() - .filter(|(r, _, _)| r.hash.is_some_and(|h| set.contains(&h))) + .filter(|(r, _, _, _)| r.hash.is_some_and(|h| set.contains(&h))) .collect(), } } @@ -210,9 +208,7 @@ fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> { } /// Render `ls --with-metadata` as a human-readable table. -fn print_table_with_metadata( - rows: &[(FileLocationRecord, Option, Option)], -) -> Result<(), CoreError> { +fn print_table_with_metadata(rows: &[perima_core::FileWithMetadataRow]) -> Result<(), CoreError> { let stdout = std::io::stdout(); let mut handle = stdout.lock(); writeln!( @@ -221,7 +217,7 @@ fn print_table_with_metadata( "HASH", "SIZE", "VOLUME", "CAPTURED_AT", "DIMS", "CAMERA", ) .map_err(CoreError::from)?; - for (r, meta, _quick_hash) in rows { + for (r, meta, _quick_hash, _mount_path) in rows { let hash_hex = r.hash.map(|h| h.to_hex()); let hash_short = hash_hex.as_deref().map_or("pending ", |h| &h[..8]); let vol_str = r.volume_id.0.to_string(); diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index eefd394..c38d5d3 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! CLI subcommand modules. +pub(crate) mod auth; pub(crate) mod backup; pub(crate) mod debug_report; pub(crate) mod dedup; @@ -11,5 +12,6 @@ pub(crate) mod migrate_data_dir; pub(crate) mod scan; pub(crate) mod search; pub(crate) mod tag; +pub(crate) mod transcribe; pub(crate) mod volumes; pub(crate) mod watch; diff --git a/crates/cli/src/cmd/transcribe.rs b/crates/cli/src/cmd/transcribe.rs new file mode 100644 index 0000000..76a111e --- /dev/null +++ b/crates/cli/src/cmd/transcribe.rs @@ -0,0 +1,510 @@ +//! `perima transcribe` subcommand. +//! +//! Runs a transcription via the active provider, prints transcript text to +//! stdout (or `--output `), exits 0 on success. +//! +//! # Concurrency model +//! +//! The CLI shell-side handler builds an `AppContainer`, registers a +//! [`TerminalEventHandler`] (extra handler) that funnels terminal +//! `AppEvent::Transcription{Completed,Failed,Cancelled}` events into a +//! `flume` channel, sends `TranscribeCommand::Start`, and blocks on the +//! channel receive. The pre-completion `AppEvent::TranscriptionProgress` +//! frames stream as one-line-per-event status updates on stderr +//! (best-effort `writeln!`; cloud adapters in v1 emit Started/Finished +//! only, so the per-segment overwrite would be wasted complexity). +//! +//! # Why a synthetic `file_uuid` +//! +//! The CLI's one-shot flow has no row in the `files` table for the +//! source path (no scan happened first). The `transcript` schema's +//! `file_uuid` column is FK-without-cascade and does NOT enforce +//! existence at the DB level, so a fresh `UUIDv7` per CLI invocation is +//! safe and avoids leaking a fake `files` row. + +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use clap::{Args, ValueEnum}; +use perima_app::{AppContainer, EventHandler, TranscribeCommand, TranscribeOutput}; +use perima_core::transcription::TranscriptionError; +use perima_core::{AppEvent, CoreError}; + +use crate::signals::Cancellation; + +/// Output format for the transcribed text. +#[derive(Copy, Clone, Debug, ValueEnum)] +pub(crate) enum OutputFormat { + /// Plain text — one line per segment. + Text, + /// JSON — `{ "language", "duration_ms", "segments": [...] }`. + Json, +} + +/// Arguments for `perima transcribe`. +#[derive(Args, Debug)] +pub(crate) struct TranscribeArgs { + /// Path to the source media file (audio or video). + pub source: PathBuf, + + /// Optional BCP-47 language hint (e.g. `en`, `es`, `zh-Hans`). + /// + /// Loose RFC-5646-subset validation: 2-3 letter language with + /// optional 4-letter script and optional 2-letter or 3-digit region. + #[arg(long, value_parser = parse_bcp47)] + pub language: Option, + + /// Write transcript to FILE instead of stdout. + #[arg(long)] + pub output: Option, + + /// Output format: `text` (default) or `json`. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, +} + +/// CLI exit-code categories. +/// +/// 0 = success, 1 = generic error, 2 = auth error, +/// 3 = queue full, 130 = cancelled (POSIX SIGINT convention). +pub(crate) const EXIT_AUTH: u8 = 2; +pub(crate) const EXIT_QUEUE_FULL: u8 = 3; +pub(crate) const EXIT_CANCELLED: u8 = 130; + +/// Loose BCP-47 shape validator. Promote to `unic-langid` for full +/// RFC 5646 handling — tracked as out-of-scope per spec. +fn parse_bcp47(s: &str) -> Result { + static BCP47_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + // ^lang(2-3 alpha) (-script(4 alpha))? (-region(2 alpha or 3 digit))? $ + regex::Regex::new(r"^[A-Za-z]{2,3}(-[A-Za-z]{4})?(-[A-Za-z]{2}|-[0-9]{3})?$") + .expect("static regex compiles") + }); + if BCP47_RE.is_match(s) { + Ok(s.to_owned()) + } else { + Err(format!("invalid BCP-47 language code: {s}")) + } +} + +/// Map a [`TranscriptionError`] variant to the matching CLI exit code. +#[must_use] +pub(crate) const fn exit_code_for(err: &TranscriptionError) -> u8 { + match err { + TranscriptionError::Auth | TranscriptionError::QuotaExceeded => EXIT_AUTH, + TranscriptionError::QueueFull { .. } => EXIT_QUEUE_FULL, + TranscriptionError::Cancelled => EXIT_CANCELLED, + _ => 1, + } +} + +// --------------------------------------------------------------------------- +// Terminal-event handler +// --------------------------------------------------------------------------- + +/// One terminal outcome for a transcription request. +#[derive(Debug)] +pub(crate) enum Terminal { + /// Successful completion. `transcript_id` lets the dispatcher load the + /// freshly-persisted segments via a short-lived read connection. + Completed { transcript_id: String }, + /// Cancelled by the user (Ctrl-C). + Cancelled, + /// Failed with a typed [`TranscriptionError`]. + Failed(TranscriptionError), +} + +/// Funnel terminal `AppEvent::Transcription*` events into a `flume` +/// channel. The dispatcher constructs one of these BEFORE container +/// build, registers it as an `extra_handler`, then sets the request UUID +/// AFTER `Start` returns (the use-case mints the UUID). +/// +/// WHY a shared `Arc>>` for the request UUID rather +/// than a per-handler value: the handler must be registered during +/// `AppContainer::new`, but the request UUID does not exist until after +/// `TranscribeCommand::Start` runs. The shared cell lets the dispatcher +/// fill in the UUID retroactively. Until it is set, all transcription +/// events fall through (matches the "ignore until we know our UUID" +/// invariant). +struct TerminalEventHandler { + /// Set by the dispatcher AFTER `Start` returns. Until then, all + /// transcription events are ignored. + request_uuid: Arc>>, + tx: flume::Sender, +} + +#[async_trait::async_trait] +impl EventHandler for TerminalEventHandler { + fn name(&self) -> &'static str { + "cli_transcribe_terminal" + } + + async fn handle(&mut self, event: AppEvent) { + let target = match self.request_uuid.lock() { + Ok(g) => g.clone(), + Err(_) => return, // poisoned — drop the event silently. + }; + let Some(target) = target else { return }; + // WHY `let _ = self.tx.send(...)`: the bounded(4) channel's + // receiver is the dispatcher's terminal-event blocking recv; if + // it's already gone (caller exited via Ctrl-C), dropping the + // event is the correct behaviour, not surfacing a SendError. + match event { + AppEvent::TranscriptionCompleted { + request_uuid, + transcript_id, + .. + } if request_uuid == target => { + let _ = self.tx.send(Terminal::Completed { transcript_id }); + } + AppEvent::TranscriptionCancelled { request_uuid } if request_uuid == target => { + let _ = self.tx.send(Terminal::Cancelled); + } + AppEvent::TranscriptionFailed { + request_uuid, + error, + } if request_uuid == target => { + let _ = self.tx.send(Terminal::Failed(error)); + } + AppEvent::TranscriptionProgress { + request_uuid, + processed_ms, + total_ms, + } if request_uuid == target => { + // One-line stderr status. Best-effort — failure to write + // is silent (broken pipe, redirected stderr). + let elapsed = ms_to_secs(processed_ms); + let total = + total_ms.map_or_else(|| "?".to_owned(), |ms| format!("{:.1}", ms_to_secs(ms))); + let _ = writeln!( + std::io::stderr(), + "transcribing... {elapsed:.1}s / {total}s" + ); + } + _ => {} + } + } +} + +#[allow(clippy::cast_precision_loss)] +fn ms_to_secs(ms: u32) -> f32 { + ms as f32 / 1000.0 +} + +/// Shared cell the dispatcher fills with the request UUID after `Start`. +pub(crate) type RequestUuidCell = Arc>>; + +/// Bundle returned by [`make_terminal_handler`]. +pub(crate) struct TerminalHandle { + /// Boxed handler — pass to `build_container` as an `extra_handler`. + pub handler: Box, + /// Receiver the dispatcher reads from. + pub rx: flume::Receiver, + /// Shared cell the dispatcher fills with the request UUID after `Start`. + pub request_uuid: RequestUuidCell, +} + +/// Construct the terminal handler + receiver + request-UUID cell. +pub(crate) fn make_terminal_handler() -> TerminalHandle { + let (tx, rx) = flume::bounded::(4); + let request_uuid: RequestUuidCell = Arc::new(std::sync::Mutex::new(None)); + let handler = TerminalEventHandler { + request_uuid: Arc::clone(&request_uuid), + tx, + }; + TerminalHandle { + handler: Box::new(handler), + rx, + request_uuid, + } +} + +// --------------------------------------------------------------------------- +// Read transcript text out of the DB +// --------------------------------------------------------------------------- + +/// Read every segment for `transcript_id` (ordered by `start_ms`) into a +/// flat `Vec<(start_ms, end_ms, text)>`. Uses a short-lived read-only +/// connection — there is no T7 read API yet. +fn load_segments_for( + db_path: &std::path::Path, + transcript_id: &str, +) -> Result, CoreError> { + let conn = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| CoreError::Internal(format!("open transcript db: {e}")))?; + let mut stmt = conn + .prepare( + "SELECT start_ms, end_ms, text FROM transcript_segment \ + WHERE transcript_id = ?1 AND deleted_at IS NULL \ + ORDER BY start_ms ASC", + ) + .map_err(|e| CoreError::Internal(format!("prepare segments query: {e}")))?; + // WHY u32::try_from(i64).unwrap_or(u32::MAX): SQLite stores INTEGER + // as i64; schema values are ms timestamps that fit in u32 for any + // realistic media file (u32::MAX ms ≈ 49 days). Saturating on the + // (impossible) overflow is preferable to crashing the render — the + // segment is still readable and the UX degrades to "very long" rather + // than aborting the whole transcript print. + let rows = stmt + .query_map([transcript_id], |row| { + let start = row.get::<_, i64>(0)?; + let end = row.get::<_, i64>(1)?; + let text = row.get::<_, String>(2)?; + Ok(( + u32::try_from(start).unwrap_or(u32::MAX), + u32::try_from(end).unwrap_or(u32::MAX), + text, + )) + }) + .map_err(|e| CoreError::Internal(format!("query segments: {e}")))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CoreError::Internal(format!("read segment row: {e}")))?); + } + Ok(out) +} + +/// Read the transcript header (`language`, `duration_ms`) for the given id. +fn load_transcript_header( + db_path: &std::path::Path, + transcript_id: &str, +) -> Result<(Option, u32), CoreError> { + let conn = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| CoreError::Internal(format!("open transcript db: {e}")))?; + let row = conn + .query_row( + "SELECT language, duration_ms FROM transcript WHERE id = ?1", + [transcript_id], + |r| { + let lang = r.get::<_, Option>(0)?; + let dur = r.get::<_, i64>(1)?; + Ok((lang, u32::try_from(dur).unwrap_or(u32::MAX))) + }, + ) + .map_err(|e| CoreError::Internal(format!("query transcript header: {e}")))?; + Ok(row) +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +fn render_text(w: &mut W, segments: &[(u32, u32, String)]) -> Result<(), CoreError> { + for (_, _, text) in segments { + writeln!(w, "{}", text.trim()).map_err(CoreError::from)?; + } + Ok(()) +} + +fn render_json( + w: &mut W, + language: Option<&str>, + duration_ms: u32, + segments: &[(u32, u32, String)], +) -> Result<(), CoreError> { + let segs: Vec = segments + .iter() + .map(|(s, e, t)| { + serde_json::json!({ + "start_ms": s, + "end_ms": e, + "text": t, + }) + }) + .collect(); + let body = serde_json::json!({ + "language": language, + "duration_ms": duration_ms, + "segments": segs, + }); + serde_json::to_writer_pretty(&mut *w, &body) + .map_err(|e| CoreError::Internal(format!("json: {e}")))?; + writeln!(w).map_err(CoreError::from) +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// Render the persisted transcript to either stdout or `--output`. +fn render_output( + db_path: &std::path::Path, + transcript_id: &str, + args: &TranscribeArgs, +) -> Result<(), CoreError> { + let segments = load_segments_for(db_path, transcript_id)?; + let (language, duration_ms) = load_transcript_header(db_path, transcript_id)?; + + if let Some(out_path) = &args.output { + let mut file = std::fs::File::create(out_path).map_err(CoreError::from)?; + match args.format { + OutputFormat::Text => render_text(&mut file, &segments), + OutputFormat::Json => { + render_json(&mut file, language.as_deref(), duration_ms, &segments) + } + } + } else { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + match args.format { + OutputFormat::Text => render_text(&mut handle, &segments), + OutputFormat::Json => { + render_json(&mut handle, language.as_deref(), duration_ms, &segments) + } + } + } +} + +/// Wait for the terminal event OR Ctrl-C. The cancel token (Ctrl-C) +/// takes precedence. +async fn await_terminal( + container: &Arc, + request_uuid: &str, + rx: flume::Receiver, + cancel: &Cancellation, +) -> Result { + let cancel_token = cancel.token(); + let terminal = tokio::select! { + recv = rx.recv_async() => recv, + () = cancel_token.cancelled() => { + // Best-effort cancel — the use-case removes the entry and + // fires the token; the worker emits TranscriptionCancelled + // which our handler funnels back. Bound the wait so a stuck + // worker can't hang the CLI. + let _ = container + .transcription + .execute(TranscribeCommand::Cancel { + request_uuid: request_uuid.to_owned(), + }) + .await; + let Ok(r) = tokio::time::timeout(Duration::from_secs(5), rx.recv_async()).await else { + eprintln!("perima: cancelled (worker did not acknowledge in 5s)"); + return Err(EXIT_CANCELLED); + }; + r + } + }; + terminal.map_err(|_| { + eprintln!("perima: event bus closed before terminal event"); + 1 + }) +} + +/// Outcome of the dispatcher: the exit-code byte the caller passes to +/// `ExitCode::from`. +pub(crate) async fn run( + container: Arc, + db_path: &std::path::Path, + args: TranscribeArgs, + rx: flume::Receiver, + request_uuid_cell: RequestUuidCell, + cancel: &Cancellation, +) -> u8 { + if !args.source.exists() { + eprintln!("perima: source not found: {}", args.source.display()); + return 1; + } + + let file_name = args.source.file_name().map_or_else( + || args.source.display().to_string(), + |s| s.to_string_lossy().into_owned(), + ); + + let request = TranscribeCommand::Start { + file_uuid: uuid::Uuid::now_v7().simple().to_string(), + file_name, + source: args.source.clone(), + language_hint: args.language.clone(), + }; + + let started = match container.transcription.execute(request).await { + Ok(out) => out, + Err(CoreError::Transcription(t)) => { + eprintln!("perima: {t}"); + return exit_code_for(&t); + } + Err(e) => { + eprintln!("perima: {e}"); + return 1; + } + }; + + let TranscribeOutput::Started { request_uuid, .. } = started else { + eprintln!("perima: unexpected use-case output (Cancelled before Start)"); + return 1; + }; + + // Make the handler aware of our request UUID NOW. Any events that + // arrived before this assignment were dropped (matches the "ignore + // until we know our UUID" invariant on `TerminalEventHandler`). + if let Ok(mut g) = request_uuid_cell.lock() { + *g = Some(request_uuid.clone()); + } + + let terminal = match await_terminal(&container, &request_uuid, rx, cancel).await { + Ok(t) => t, + Err(code) => return code, + }; + + match terminal { + Terminal::Completed { transcript_id } => { + if let Err(e) = render_output(db_path, &transcript_id, &args) { + eprintln!("perima: {e}"); + return 1; + } + 0 + } + Terminal::Cancelled => { + eprintln!("perima: transcription cancelled"); + EXIT_CANCELLED + } + Terminal::Failed(err) => { + eprintln!("perima: {err}"); + exit_code_for(&err) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn bcp47_validator_accepts_common_codes() { + assert!(parse_bcp47("en").is_ok()); + assert!(parse_bcp47("es").is_ok()); + assert!(parse_bcp47("zh-Hans").is_ok()); + assert!(parse_bcp47("en-US").is_ok()); + assert!(parse_bcp47("es-419").is_ok()); + } + + #[test] + fn bcp47_validator_rejects_garbage() { + assert!(parse_bcp47("not-a-real-tag-zzz").is_err()); + assert!(parse_bcp47("12345").is_err()); + assert!(parse_bcp47("").is_err()); + } + + #[test] + fn exit_code_mapping_matches_spec() { + assert_eq!(exit_code_for(&TranscriptionError::Auth), EXIT_AUTH); + assert_eq!(exit_code_for(&TranscriptionError::QuotaExceeded), EXIT_AUTH); + assert_eq!( + exit_code_for(&TranscriptionError::QueueFull { queued: 32 }), + EXIT_QUEUE_FULL + ); + assert_eq!( + exit_code_for(&TranscriptionError::Cancelled), + EXIT_CANCELLED + ); + assert_eq!(exit_code_for(&TranscriptionError::Network("dns".into())), 1); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 128719b..51e4878 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -41,6 +41,25 @@ use perima_media::ThumbnailGenerator; use crate::config::Config; use crate::signals::Cancellation; +/// Stub [`perima_transcribe::audio::AudioPipeline`] used when ffmpeg is not on +/// PATH at startup. Surfaces a typed `BinaryNotFound` only if a transcription +/// job actually runs; non-transcription commands (scan, tag, search, etc.) +/// proceed normally. +struct MissingFfmpegPipeline; + +impl perima_transcribe::audio::AudioPipeline for MissingFfmpegPipeline { + fn remux_for_upload( + &self, + _input: &std::path::Path, + _cancel: &tokio_util::sync::CancellationToken, + ) -> Result { + Err(perima_transcribe::audio::AudioError::BinaryNotFound( + "ffmpeg not found on PATH at startup; install via apt/brew/winget and re-launch" + .to_owned(), + )) + } +} + /// Cross-platform media asset manager. #[derive(Parser, Debug)] #[command( @@ -163,6 +182,20 @@ enum Command { /// desktop-shared path. Only needed if you used perima CLI before v0.7.0 /// (GH #154). Safe to run repeatedly — refuses to overwrite an existing DB. MigrateDataDir(cmd::migrate_data_dir::MigrateDataDirArgs), + + /// Transcribe a media file via the active provider. + /// + /// Prints the transcript text to stdout (or `--output FILE`). Streams + /// per-segment progress to stderr. Exits 0 on success, 2 on auth / + /// quota errors, 3 on queue full, 130 on Ctrl-C, 1 otherwise. + Transcribe(cmd::transcribe::TranscribeArgs), + + /// Manage transcription-provider API keys (keyring entries). + /// + /// `set` prompts for a key with hidden input; `delete` removes an + /// entry idempotently; `has` exits 0 / 1; `list` prints configured + /// providers + key presence. + Auth(cmd::auth::AuthArgs), } /// Entry point. @@ -175,6 +208,16 @@ async fn main() -> ExitCode { panic::install(); let cli = Cli::parse(); + // WHY env-var-gated keyring mock: `keyring::set_default_credential_builder` + // is process-local (cannot cross a subprocess boundary). The integration + // tests under `tests/transcribe_test.rs` set this env var to keep `auth + // {set,delete,has,list}` in-memory so a `cargo test` run does not pollute + // the developer's real OS keyring. Production users never set this. + // The call MUST happen before any `keyring::Entry::new` call elsewhere. + if std::env::var_os("PERIMA_KEYRING_MOCK").is_some() { + keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); + } + // WHY _log_guard (not _): `tracing-appender` non-blocking writer is // backed by a background flush thread. Binding to `_` drops the // WorkerGuard immediately (RAII), killing the thread and losing any @@ -256,6 +299,10 @@ async fn main() -> ExitCode { } => dispatch_debug_report(path, include_rotated), Command::MigrateDataDir(args) => dispatch_migrate_data_dir(&args), + + Command::Transcribe(args) => dispatch_transcribe(args, &config, &cancel).await, + + Command::Auth(args) => dispatch_auth(&args, &config), } } @@ -292,7 +339,7 @@ fn dispatch_migrate_data_dir(args: &cmd::migrate_data_dir::MigrateDataDirArgs) - /// Run the `backup` subcommand. async fn dispatch_backup(args: &cmd::backup::BackupArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -331,21 +378,19 @@ async fn dispatch_backup(args: &cmd::backup::BackupArgs, config: &Config) -> Exi /// shared bus without constructing a second bus in the shell. fn build_container( db_path: &Path, + config_dir: &Path, + device_id: perima_core::DeviceId, extra_handlers: Vec>, ) -> Result, perima_core::CoreError> { - // WHY a `NoopBus` passed to the writer: the writer's after-COMMIT - // emission path is scaffolded but file-event emission is handled by - // the async-broadcast Bus wired into `AppContainer`. The writer bus - // is separate from the handler list — it receives post-COMMIT events - // from the writer thread (std::thread, not tokio), so it must be - // Arc (sync emit). Spec §§3.3 + 4.8 (A4.8 first bullet). - struct NoopBus; - impl perima_core::events::EventBus for NoopBus { - fn emit(&self, _: &perima_core::AppEvent) -> Result<(), perima_core::CoreError> { - Ok(()) - } - } - let writer_bus: Arc = Arc::new(NoopBus); + // WHY construct the Bus here (not inside AppContainer::new): the writer + // actor needs the SAME bus the use-cases publish to, otherwise + // `AppEvent::TranscriptionCompleted` (writer-side, post-COMMIT) never + // reaches handlers registered on the container's bus. T6 introduced + // `AppContainer::new_with_bus` for this — the bus stays the single + // construction site per process, but the construction MOVES from + // `AppContainer::new` into the shell. + let bus = perima_app::Bus::new(); + let writer_bus: Arc = bus.clone(); let writer = SqliteWriter::start(db_path, writer_bus)?; let reads = ReadPool::open(db_path)?; @@ -395,6 +440,27 @@ fn build_container( .unwrap_or_else(|| Path::new(".")) .to_path_buf(); + // Transcript repo + audio pipeline for the transcription use-case. + // WHY a separate `ReadPool::open` for the transcript repo is NOT needed: + // we reuse a fresh read pool clone (same `db_path`) — every other adapter + // already holds a clone of the same r2d2-backed pool. + let transcript_repo: Arc = Arc::new( + perima_db::SqliteTranscriptRepository::new(writer.sender(), ReadPool::open(db_path)?), + ); + // WHY discover-or-skip: missing ffmpeg is fine for non-transcription + // commands. We construct a deferred-error pipeline that surfaces + // `BinaryNotFound` only if a transcription job actually runs. + let audio_pipeline: Arc = + match perima_transcribe::audio::CliFfmpegInvoker::discover() { + Ok(invoker) => Arc::new(perima_transcribe::audio::FfmpegAudioPipeline::new( + Arc::new(invoker), + )), + Err(e) => { + tracing::info!(error = %e, "ffmpeg not on PATH; transcription will be unavailable"); + Arc::new(MissingFfmpegPipeline) + } + }; + let deps = AppDeps { admin, data_dir, @@ -407,6 +473,10 @@ fn build_container( hasher, scanner, thumbnailer, + transcript_repo, + audio_pipeline, + config_dir: config_dir.to_path_buf(), + device_id, }; // WHY log handler always first: every command benefits from tracing @@ -415,7 +485,7 @@ fn build_container( let log_handler: Box = Box::new(perima_app::LogEventHandler); let mut handlers: Vec> = vec![log_handler]; handlers.extend(extra_handlers); - Ok(AppContainer::new(deps, handlers)) + Ok(AppContainer::new_with_bus(deps, handlers, bus)) } /// Build a `DbEventHandler` for the `watch` command, boxed as `Box`. @@ -488,7 +558,7 @@ async fn dispatch_scan( // volume detection internally — no split path needed. Building the // container still requires migrations to have run, which is // harmless for a fresh dry-run against an empty data dir. - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: {e}"); @@ -678,7 +748,7 @@ async fn dispatch_ls( } }; let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -708,7 +778,7 @@ async fn dispatch_ls( /// Run the `tag` subcommand. async fn dispatch_tag(args: &cmd::tag::TagArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -727,7 +797,7 @@ async fn dispatch_tag(args: &cmd::tag::TagArgs, config: &Config) -> ExitCode { /// Run the `hash` subcommand. async fn dispatch_hash(args: &cmd::hash::HashArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -750,7 +820,7 @@ async fn dispatch_hash(args: &cmd::hash::HashArgs, config: &Config) -> ExitCode /// Run the `dedup` subcommand. async fn dispatch_dedup(args: &cmd::dedup::DedupArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -783,7 +853,12 @@ async fn dispatch_watch(root: PathBuf, config: &Config, cancel: &Cancellation) - } }; - let container = match build_container(&db_path, vec![db_handler]) { + let container = match build_container( + &db_path, + &config.config_dir, + config.device_id, + vec![db_handler], + ) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -818,7 +893,7 @@ async fn dispatch_metadata(path: PathBuf, json: bool, config: &Config) -> ExitCo // WHY build_container here: `cmd::metadata::run` now consumes // `AppContainer.volumes` for `find_or_create` (Batch C Task 2). let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); @@ -841,7 +916,7 @@ async fn dispatch_metadata(path: PathBuf, json: bool, config: &Config) -> ExitCo /// Run the `search` subcommand. async fn dispatch_search(args: &cmd::search::SearchArgs, config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database (search): {e}"); @@ -857,10 +932,64 @@ async fn dispatch_search(args: &cmd::search::SearchArgs, config: &Config) -> Exi } } +/// Run the `transcribe` subcommand. +/// +/// Builds the container with a terminal `EventHandler` +/// (built by [`cmd::transcribe::make_terminal_handler`]) pre-injected as +/// an `extra_handler`. The handler funnels terminal + progress +/// `AppEvent::Transcription*` events back into the dispatcher via a +/// `flume` channel. The dispatcher fills in the request UUID after +/// `Start` returns, then awaits the terminal event (or Ctrl-C). +async fn dispatch_transcribe( + args: cmd::transcribe::TranscribeArgs, + config: &Config, + cancel: &Cancellation, +) -> ExitCode { + let db_path = config.data_dir.join("perima.db"); + let handle = cmd::transcribe::make_terminal_handler(); + + let container = match build_container( + &db_path, + &config.config_dir, + config.device_id, + vec![handle.handler], + ) { + Ok(c) => c, + Err(e) => { + eprintln!("perima: database: {e}"); + return ExitCode::from(1); + } + }; + + let code = cmd::transcribe::run( + container, + &db_path, + args, + handle.rx, + handle.request_uuid, + cancel, + ) + .await; + ExitCode::from(code) +} + +/// Run the `auth` subcommand. +/// +/// Pure synchronous keyring + config operations — no container needed. +fn dispatch_auth(args: &cmd::auth::AuthArgs, config: &Config) -> ExitCode { + match cmd::auth::run(args, &config.config_dir) { + Ok(code) => ExitCode::from(code), + Err(e) => { + eprintln!("perima: {e}"); + ExitCode::from(1) + } + } +} + /// Run the `volumes` subcommand. async fn dispatch_volumes(config: &Config) -> ExitCode { let db_path = config.data_dir.join("perima.db"); - let container = match build_container(&db_path, vec![]) { + let container = match build_container(&db_path, &config.config_dir, config.device_id, vec![]) { Ok(c) => c, Err(e) => { eprintln!("perima: database: {e}"); diff --git a/crates/cli/tests/transcribe_test.rs b/crates/cli/tests/transcribe_test.rs new file mode 100644 index 0000000..d87a7fd --- /dev/null +++ b/crates/cli/tests/transcribe_test.rs @@ -0,0 +1,308 @@ +//! Integration tests: `perima transcribe` and `perima auth` subcommands. +//! +//! Drives the CLI binary (`assert_cmd::Command::cargo_bin("perima")`) +//! against a wiremock-stubbed Groq-shaped HTTP endpoint. A synthetic +//! 1-second 16 kHz mono WAV (via `hound`) stands in for real media. +//! +//! ## How the keyring is bypassed in tests +//! +//! `keyring::mock::default_credential_builder` is process-local: a test +//! process cannot pre-seed the spawned CLI subprocess's mock store. The +//! container reads provider api keys via two paths: +//! +//! 1. `PERIMA_TEST_API_KEY_` env var (intended ONLY for tests). +//! If set, this value short-circuits the keyring lookup. The CLI sees +//! the api key as if a real keyring entry existed. +//! 2. The platform keyring otherwise. +//! +//! The auth-subcommand tests (`auth_*`) flip `PERIMA_KEYRING_MOCK=1` so +//! the subprocess keyring lives in memory; that avoids polluting the +//! developer's real OS keyring during `cargo test`. +//! +//! ## Why a real wiremock server, not a mocked Transcriber trait +//! +//! Mocking the trait would skip the entire HTTP request shape — the test +//! would pass even if the CLI accidentally sent the wrong endpoint or +//! omitted required multipart parts. wiremock asserts the wire shape. + +#![allow(clippy::unwrap_used)] +#![allow(clippy::print_stdout)] + +use std::io::Write; +use std::path::Path; + +use assert_cmd::Command; +use wiremock::matchers::{method, path as wm_path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const fn bin() -> &'static str { + env!("CARGO_BIN_EXE_perima") +} + +/// Synthesize a 1-second 16 kHz mono silent WAV at the given path. +fn write_silent_wav(path: &Path) { + let spec = hound::WavSpec { + channels: 1, + sample_rate: 16_000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::create(path, spec).unwrap(); + for _ in 0..16_000 { + writer.write_sample(0_i16).unwrap(); + } + writer.finalize().unwrap(); +} + +/// Verbose-JSON response body matching the `OpenAI` / Groq schema. One +/// segment with the text "hello world" — the happy-path test asserts +/// this surfaces on stdout. +fn verbose_json_body() -> serde_json::Value { + serde_json::json!({ + "task": "transcribe", + "language": "en", + "duration": 1.0_f32, + "text": "hello world", + "segments": [{ + "id": 0, + "seek": 0, + "start": 0.0_f32, + "end": 1.0_f32, + "text": "hello world", + "tokens": [], + "temperature": 0.0_f32, + "avg_logprob": -0.1_f32, + "compression_ratio": 1.0_f32, + "no_speech_prob": 0.0_f32, + }], + "usage": { "type": "duration", "seconds": 1.0_f32 }, + }) +} + +/// Write a `config.toml` that wires a single `custom`-preset provider at +/// the wiremock server's URL with a placeholder api key. +fn write_test_config(config_dir: &Path, base_url: &str) { + let body = format!( + "[transcription]\nactive_provider = \"test\"\n\n\ + [transcription.providers.test]\npreset = \"custom\"\nbase_url = \"{base_url}\"\nmodel = \"whisper-1\"\n" + ); + let path = config_dir.join("config.toml"); + let mut f = std::fs::File::create(path).unwrap(); + f.write_all(body.as_bytes()).unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn transcribe_happy_path_writes_segment_text_to_stdout() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(wm_path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(200).set_body_json(verbose_json_body())) + .mount(&server) + .await; + + let env_dir = tempfile::tempdir().unwrap(); + write_test_config(env_dir.path(), &server.uri()); + + let media_tmp = tempfile::tempdir().unwrap(); + let wav = media_tmp.path().join("hello.wav"); + write_silent_wav(&wav); + + // WHY spawn_blocking around `Command::output`: `output()` is sync and + // would block the tokio runtime, preventing wiremock from serving the + // CLI subprocess's HTTP request → permanent stall. spawn_blocking + // moves the wait off the runtime so wiremock's MockServer task can + // service the inbound POST. + let env_path = env_dir.path().to_path_buf(); + let wav_path = wav.clone(); + let output = tokio::task::spawn_blocking(move || { + Command::new(bin()) + .arg("transcribe") + .arg(&wav_path) + .arg("--language") + .arg("en") + .env("PERIMA_CONFIG_DIR", &env_path) + .env("PERIMA_DATA_DIR", &env_path) + // Bypass the platform keyring AND the real key lookup — the + // test injects the api key directly via this env var. + .env("PERIMA_TEST_API_KEY_test", "test-key") + .output() + .expect("spawn perima") + }) + .await + .expect("join"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "exit {:?}; stdout={stdout}; stderr={stderr}", + output.status.code() + ); + assert!( + stdout.contains("hello world"), + "expected 'hello world' on stdout; got stdout={stdout}; stderr={stderr}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn transcribe_auth_failure_exits_2() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(wm_path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "message": "Incorrect API key provided", + "type": "invalid_request_error", + "code": "invalid_api_key", + } + }))) + .mount(&server) + .await; + + let env_dir = tempfile::tempdir().unwrap(); + write_test_config(env_dir.path(), &server.uri()); + + let media_tmp = tempfile::tempdir().unwrap(); + let wav = media_tmp.path().join("auth-fail.wav"); + write_silent_wav(&wav); + + let env_path = env_dir.path().to_path_buf(); + let wav_path = wav.clone(); + let output = tokio::task::spawn_blocking(move || { + Command::new(bin()) + .arg("transcribe") + .arg(&wav_path) + .env("PERIMA_CONFIG_DIR", &env_path) + .env("PERIMA_DATA_DIR", &env_path) + .env("PERIMA_TEST_API_KEY_test", "wrong-key") + .output() + .expect("spawn perima") + }) + .await + .expect("join"); + + assert_eq!( + output.status.code(), + Some(2), + "expected exit 2 (Auth); got {:?}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn transcribe_invalid_language_rejected_by_clap() { + let env_dir = tempfile::tempdir().unwrap(); + let media_tmp = tempfile::tempdir().unwrap(); + let wav = media_tmp.path().join("bad-lang.wav"); + write_silent_wav(&wav); + + let output = Command::new(bin()) + .arg("transcribe") + .arg(&wav) + .arg("--language") + .arg("not-a-real-tag-zzz-zzz-zzz") + .env("PERIMA_CONFIG_DIR", env_dir.path()) + .env("PERIMA_DATA_DIR", env_dir.path()) + .output() + .expect("spawn perima"); + + // clap rejects with exit code 2 by default for value_parser failures. + assert_ne!(output.status.code(), Some(0)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("BCP-47") || stderr.contains("language"), + "expected BCP-47 hint in clap error; got: {stderr}" + ); +} + +#[test] +fn auth_has_returns_1_when_entry_missing() { + let env_dir = tempfile::tempdir().unwrap(); + let output = Command::new(bin()) + .arg("auth") + .arg("has") + .arg("nonexistent-provider-xyz") + .env("PERIMA_CONFIG_DIR", env_dir.path()) + .env("PERIMA_DATA_DIR", env_dir.path()) + .env("PERIMA_KEYRING_MOCK", "1") + .output() + .expect("spawn perima"); + + assert_eq!(output.status.code(), Some(1)); +} + +#[test] +fn auth_set_succeeds_and_delete_is_idempotent() { + let env_dir = tempfile::tempdir().unwrap(); + + // `set` reads from stdin when it isn't on a TTY (assert_cmd's pipe is non-TTY). + let mut set = std::process::Command::new(bin()) + .arg("auth") + .arg("set") + .arg("groq") + .env("PERIMA_CONFIG_DIR", env_dir.path()) + .env("PERIMA_DATA_DIR", env_dir.path()) + .env("PERIMA_KEYRING_MOCK", "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn auth set"); + set.stdin + .as_mut() + .expect("stdin") + .write_all(b"my-secret-key\n") + .expect("write stdin"); + let set_out = set.wait_with_output().expect("wait set"); + assert!( + set_out.status.success(), + "auth set failed: {}", + String::from_utf8_lossy(&set_out.stderr) + ); + + // The mock keyring is process-local, so we cannot follow up with `auth has` + // in a separate subprocess and expect the entry to still be there — each + // `Command::new(bin())` invocation is a fresh subprocess, fresh in-memory + // mock. We only assert `set` succeeds end-to-end here. + + // `delete` on a non-existent entry is idempotent — exits 0. + let del_out = Command::new(bin()) + .arg("auth") + .arg("delete") + .arg("never-existed-anywhere") + .env("PERIMA_CONFIG_DIR", env_dir.path()) + .env("PERIMA_DATA_DIR", env_dir.path()) + .env("PERIMA_KEYRING_MOCK", "1") + .output() + .expect("spawn auth delete"); + assert!( + del_out.status.success(), + "auth delete must be idempotent; got {:?}", + del_out.status.code() + ); +} + +#[test] +fn auth_list_with_empty_config_prints_no_providers() { + let env_dir = tempfile::tempdir().unwrap(); + let output = Command::new(bin()) + .arg("auth") + .arg("list") + .env("PERIMA_CONFIG_DIR", env_dir.path()) + .env("PERIMA_DATA_DIR", env_dir.path()) + .env("PERIMA_KEYRING_MOCK", "1") + .output() + .expect("spawn perima"); + + assert!( + output.status.success(), + "auth list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("no providers") || stdout.contains("active provider: (none)"), + "expected empty-config marker; got: {stdout}" + ); +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 3154a8d..ec09408 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,6 +11,7 @@ publish = false # WHY: intra-workspace only; not published to crates.io thiserror.workspace = true serde.workspace = true uuid.workspace = true +tokio-util.workspace = true unicode-normalization.workspace = true # WHY features = ["derive", "uuid"]: specta::Type proc-macro lives behind # the "derive" feature. The "uuid" feature adds specta::Type impl for diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs index 5110b29..86e8d83 100644 --- a/crates/core/src/errors.rs +++ b/crates/core/src/errors.rs @@ -62,6 +62,10 @@ pub enum CoreError { #[error("unsupported in this phase: {0}")] Unsupported(String), + /// Transcription failure (cloud or local STT backend). + #[error("transcription failed: {0}")] + Transcription(#[from] crate::transcription::TranscriptionError), + /// Any adapter-level failure that didn't map to a typed variant. #[error("internal: {0}")] Internal(String), diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs index 939b3b1..c1c859d 100644 --- a/crates/core/src/events.rs +++ b/crates/core/src/events.rs @@ -5,6 +5,7 @@ use serde::Serialize; use crate::{ CoreError, FileUuid, MediaPath, VolumeId, dedup::{BatchId, FullHashOutcome}, + transcription::TranscriptionError, }; /// A filesystem event detected by the watcher. @@ -121,6 +122,75 @@ pub enum AppEvent { /// The batch that has finished. batch_id: BatchId, }, + + /// Transcription job has been picked up by the worker and is starting. + /// + /// Emitted once per request immediately before the adapter call. The + /// frontend uses this to flip the per-file UI from "queued" to "running" + /// without polling. + TranscriptionStarted { + /// Per-request `UUIDv7` (lowercase-hex, simple form). Pairs with the + /// `request_uuid` returned by `TranscribeOutput::Started`. + request_uuid: String, + /// File the job is transcribing (immutable surrogate). + file_uuid: String, + /// Display name for UI surfacing (file basename or curated label). + file_name: String, + /// Current queue size including this job (1 = this is the only job). + queue_size: u32, + }, + + /// Mid-flight transcription progress. + /// + /// Driven by adapter `TranscriptionProgress::Segment` and `Heartbeat` + /// callbacks. `processed_ms` is the cumulative ms of source media + /// finalized so far; `total_ms` may be `None` for backends that do not + /// publish a duration estimate up front. + TranscriptionProgress { + /// Per-request `UUIDv7`. + request_uuid: String, + /// Cumulative milliseconds of source media processed. + processed_ms: u32, + /// Total source duration in milliseconds, when known. + total_ms: Option, + }, + + /// Transcription job completed successfully; transcript persisted. + /// + /// Emitted by the writer-cmd handler AFTER `COMMIT`. The frontend uses + /// `transcript_id` to refetch the freshly-inserted row and `request_uuid` + /// to dismiss the matching in-flight slot in its job map. + TranscriptionCompleted { + /// Per-request `UUIDv7` (threaded from the use-case via + /// `WriteCmd::Transcript(TranscriptWriteCmd::Insert)`). + request_uuid: String, + /// Persisted transcript header row id (`UUIDv7` simple-hex). + transcript_id: String, + /// File the transcript belongs to (immutable surrogate). + file_uuid: String, + /// Number of segment rows written. + segment_count: u32, + /// Detected language (BCP-47 short code) when the backend reports one. + language: Option, + }, + + /// Transcription job cancelled by user (token fired before commit). + TranscriptionCancelled { + /// Per-request `UUIDv7`. + request_uuid: String, + }, + + /// Transcription job failed (non-cancel terminal error). + /// + /// Carries the full [`TranscriptionError`] so the frontend can surface + /// discriminant payloads such as `RateLimited.retry_after_secs` or + /// `FileTooLarge.limit_bytes` without a lossy re-stringification. + TranscriptionFailed { + /// Per-request `UUIDv7`. + request_uuid: String, + /// The terminal error that ended the job. + error: TranscriptionError, + }, } /// Categorical reason an index was invalidated. diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cad20e4..e922d7d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -12,6 +12,7 @@ pub mod ids; pub mod metadata; pub mod search; pub mod tag; +pub mod transcription; pub mod types; pub use dedup::{BatchHandle, BatchId, CollisionGroup, DeviceKind, FullHashOutcome, VerifiedState}; diff --git a/crates/core/src/ports/metadata_repo.rs b/crates/core/src/ports/metadata_repo.rs index fd03d20..d27829f 100644 --- a/crates/core/src/ports/metadata_repo.rs +++ b/crates/core/src/ports/metadata_repo.rs @@ -4,10 +4,10 @@ use crate::{ BlakeHash, CoreError, DeviceId, FileLocationRecord, MediaMetadata, UpsertOutcome, VolumeId, }; -/// A `(location, metadata, quick_hash)` row returned by +/// A `(location, metadata, quick_hash, mount_path)` row returned by /// [`MetadataRepository::list_with_metadata`]. /// -/// WHY type alias: `clippy::type_complexity` fires on the 3-tuple when it +/// WHY type alias: `clippy::type_complexity` fires on the 4-tuple when it /// appears inline in the trait method signature; a named alias both satisfies /// the lint and documents the shape in one place. /// @@ -15,7 +15,23 @@ use crate::{ /// if the backfill worker has not yet run for this row. The frontend uses /// equality with `hash` to detect placeholder rows /// (`hash == quick_hash` → full hash not yet computed). -pub type FileWithMetadataRow = (FileLocationRecord, Option, Option); +/// +/// `mount_path` is the local-filesystem mount root of the file's volume, or +/// `None` when the volume is not currently mounted on this machine. WHY a +/// separate tuple element rather than a field on [`FileLocationRecord`]: +/// mount paths are device-local — the core domain treats volumes as +/// abstract identifiers. Adapters that have access to `volume_mounts` (the +/// `SQLite` repo) populate it; mocks may leave it `None`. The desktop +/// frontend joins it with `relative_path` to materialise an `absolute_path` +/// in `crates/desktop/src/payloads.rs::FileWithMetadataPayload` so +/// downstream actions (transcribe, open-file) can hand `ffmpeg` / the OS an +/// absolute path; `None` means the action must be disabled. T9 review fix. +pub type FileWithMetadataRow = ( + FileLocationRecord, + Option, + Option, + Option, +); /// Persistence boundary for `file_metadata`. /// diff --git a/crates/core/src/transcription.rs b/crates/core/src/transcription.rs new file mode 100644 index 0000000..f968853 --- /dev/null +++ b/crates/core/src/transcription.rs @@ -0,0 +1,310 @@ +//! Transcription port: speech-to-text over audio or video files. +//! +//! Implementations live outside this crate (cloud HTTP, future local +//! whisper.cpp, future plugin sidecars) and bridge to async or blocking +//! work internally. +//! +//! Mirrors the shape of [`crate::metadata::MetadataExtractor`]: sync trait, +//! `&self`, `Send + Sync`, callable from the writer-actor without spawning +//! a second tokio runtime. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::CoreError; + +/// Stable backend identifier in `provider:model` form. +/// +/// Examples: `groq:whisper-large-v3-turbo`, `openai:whisper-1`, +/// `custom:my-self-hosted-server:large-v3-turbo`. +/// +/// Newtype kept distinct from `String` so callers cannot accidentally +/// pass a free-form display name. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct BackendId(pub String); + +impl std::fmt::Display for BackendId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Single output segment. Times in milliseconds since file start. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct TranscriptSegment { + /// `UUIDv7` assigned by the use-case (NOT the adapter), so segments + /// keep stable identity across re-runs against different backends. + pub id: Uuid, + /// Segment start time in milliseconds since file start. + pub start_ms: u32, + /// Segment end time in milliseconds since file start. + pub end_ms: u32, + /// The transcribed text for this segment. + pub text: String, + /// Roughly `[0.0, 1.0]` (1.0 = high confidence). `None` when the + /// backend does not expose a confidence signal. + pub confidence: Option, +} + +/// Result of a successful transcription. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +pub struct TranscriptionResult { + /// BCP-47 short code where possible (`en`, `fr`, `zh`). + pub language: Option, + /// Total duration of the source media in milliseconds. + pub duration_ms: u32, + /// Segments with their timestamps. + pub segments: Vec, + /// Identifier of the backend that produced this result. + pub backend: BackendId, +} + +/// Caller hint about what the adapter should do. +#[derive(Clone)] +pub struct TranscribeRequest { + /// Path to a media file (video or audio container) the adapter must + /// handle. Cloud adapters typically remux + multipart-upload. + /// Local adapters extract PCM via ffmpeg first. + pub source: PathBuf, + + /// Optional language hint. `None` = autodetect. + pub language_hint: Option, + + /// Cancel by triggering this token. Adapters MUST check at least + /// every segment boundary AND on every HTTP poll. + pub cancel: CancellationToken, + + /// Per-segment / heartbeat progress hook. Called from arbitrary + /// threads; must be `Send + Sync`. `Arc` keeps the trait + /// object-safe AND lets `TranscribeRequest: Clone` share one closure + /// across clones (cloning the request shares — does NOT duplicate — + /// the callback). + // WHY Arc over channel/Box: long-lived progress hook needs both + // Send + Sync + trait-object safety; a channel would force the request + // struct to own a tx and entangle cancellation; Box would block + // Clone of TranscribeRequest, which the queue needs to dispatch jobs. + pub on_progress: Arc, + + /// Soft timeout for HTTP-backed adapters. Local adapters ignore. + /// `None` defers to the adapter's default (typically 600s). + pub timeout: Option, +} + +// WHY manual Debug: `Arc` does not implement `Debug`; the +// closure is an opaque callback. We elide it from the debug output so the +// derived `Debug` on surrounding types still works. +impl std::fmt::Debug for TranscribeRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TranscribeRequest") + .field("source", &self.source) + .field("language_hint", &self.language_hint) + .field("cancel", &self.cancel) + .field("on_progress", &"") + .field("timeout", &self.timeout) + .finish() + } +} + +/// Progress events emitted by the adapter. +#[derive(Debug, Clone)] +pub enum TranscriptionProgress { + /// Emitted once when transcription begins. + Started { + /// Best-effort estimate of total duration in milliseconds. + estimated_duration_ms: Option, + }, + /// Emitted as segments are finalized (cloud streaming) or at segment + /// boundaries (local incremental decode). + Segment { + /// The segment that was just finalized. + segment: TranscriptSegment, + /// Cumulative milliseconds of source media processed so far. + processed_ms: u32, + /// Total source duration if known. + total_ms: Option, + }, + /// Periodic heartbeat for backends that don't stream segments + /// (e.g., `openai:whisper-1` batch mode). Drives UI spinners. + Heartbeat { + /// Time since the request started. + elapsed: Duration, + }, + /// Emitted once when transcription completes successfully. + Finished, +} + +/// All transcription failure modes. +/// +/// `#[non_exhaustive]` so new variants can land in patch releases without +/// breaking downstream `match` arms. Downstream code should either match +/// every variant explicitly or use a wildcard arm and re-audit on bumps. +/// +/// `Serialize` only (NOT `Deserialize`) — matches the [`CoreError`] +/// shape. The frontend parses the JSON via the typed-IPC contract; +/// Rust never deserializes its own errors. +// WHY no workspace-wide wildcard_enum_match_arm clippy lint: the lint is +// in the restriction group (not enabled today). Enabling it across the +// workspace is tracked as its own slice. In this crate the no-wildcard +// discipline is reviewer-enforced. +#[derive(Debug, Clone, Error, Serialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "kind", content = "data")] +#[non_exhaustive] +pub enum TranscriptionError { + /// Network error contacting the backend (DNS, TLS, connection reset, timeout). + #[error("network error contacting backend: {0}")] + Network(String), + + /// Authentication failed: bad API key, expired token, or wrong scopes. + #[error("authentication failed (bad API key, expired token, or wrong scopes)")] + Auth, + + /// Rate limited by backend; retry-after hint may be present. + #[error("rate limited by backend; retry after {retry_after_secs:?}s")] + RateLimited { + /// Seconds until retry is acceptable, parsed from `Retry-After` header. + retry_after_secs: Option, + }, + + /// Quota or billing exhausted. + #[error("quota or billing exhausted")] + QuotaExceeded, + + /// Requested model not available at the backend. + #[error("model {model} not available at backend {backend}")] + ModelNotFound { + /// Backend ID that rejected the model. + backend: String, + /// Model name requested. + model: String, + }, + + /// Could not decode audio from source. + #[error("could not decode audio from source: {0}")] + AudioDecode(String), + + /// Input file too large for backend. + #[error("input file too large for backend (limit {limit_bytes} bytes)")] + FileTooLarge { + /// Backend's file-size ceiling in bytes. + limit_bytes: u64, + }, + + /// Cancelled by caller via [`TranscribeRequest::cancel`]. + #[error("cancelled by caller")] + Cancelled, + + /// Backend service unavailable (5xx, maintenance, etc.). + #[error("backend unavailable: {reason}")] + BackendUnavailable { + /// Human-readable reason. + reason: String, + }, + + /// Transcription queue is full (bounded mpsc reached capacity). + #[error("transcription queue is full ({queued} jobs queued); try again later")] + QueueFull { + /// Number of jobs currently in the queue. + queued: u32, + }, + + /// Last-resort variant for unexpected adapter-internal failures. + /// Adapters MUST emit a `tracing::error!` AND include the + /// underlying message. Do NOT use for any classifiable failure. + #[error("internal adapter error: {0}")] + Internal(String), +} + +/// The transcription port. Sync, `&self`, `Send + Sync`. Object-safe. +/// +/// Implementations bridge to async (HTTP) or blocking CPU (whisper.cpp) +/// **internally** — see crate `transcribe` for the canonical bridges. +/// +/// # Why not `async fn` +/// 1. Existing [`crate::metadata::MetadataExtractor`] is sync; consistency wins. +/// 2. `async fn` in object-safe traits still has rough edges in stable +/// Rust (no-`dyn`-without-helpers). +/// 3. The writer-actor pattern this codebase uses needs sync +/// boundaries to keep the actor's lock-order discipline intact. +pub trait Transcriber: Send + Sync { + /// Stable backend identifier. + fn id(&self) -> &BackendId; + + /// Whether this backend handles the given MIME type. + /// + /// Mirrors [`crate::metadata::MetadataExtractor::accepts`]. Cloud + /// adapters typically accept `audio/*` and `video/*`; local adapters + /// that need PCM accept the same and rely on the use-case (or audio + /// pipeline) to pre-extract. + fn accepts(&self, mime: &str) -> bool; + + /// Run transcription. Blocks the calling thread for the duration. + /// + /// # Errors + /// All failures map into [`CoreError::Transcription`] via + /// [`TranscriptionError`]. Adapters do NOT panic on contracted + /// failures (network, auth, quota, etc.). + fn transcribe(&self, req: &TranscribeRequest) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_id_display_round_trips() { + let id = BackendId("groq:whisper-large-v3-turbo".to_owned()); + assert_eq!(id.to_string(), "groq:whisper-large-v3-turbo"); + assert_eq!(&id.0, "groq:whisper-large-v3-turbo"); + } + + #[test] + fn transcription_error_serializes_with_kind_data_tag() { + let err = TranscriptionError::RateLimited { + retry_after_secs: Some(30), + }; + let json = serde_json::to_string(&err).expect("serialize"); + // Verify discriminated-union shape per CoreError convention. + assert!(json.contains("\"kind\":\"RateLimited\""), "got {json}"); + assert!(json.contains("\"data\":{"), "got {json}"); + assert!(json.contains("\"retry_after_secs\":30"), "got {json}"); + } + + #[test] + fn transcription_error_auth_serializes_without_data() { + let err = TranscriptionError::Auth; + let json = serde_json::to_string(&err).expect("serialize"); + assert!(json.contains("\"kind\":\"Auth\""), "got {json}"); + // Auth carries no payload — verify `data` key is genuinely absent so a + // future refactor that adds a field to Auth doesn't silently slip past. + assert!(!json.contains("\"data\""), "got {json}"); + } + + #[test] + fn transcript_segment_round_trip_uuid_v7() { + let id = Uuid::now_v7(); + let seg = TranscriptSegment { + id, + start_ms: 0, + end_ms: 1500, + text: "hello world".to_owned(), + confidence: Some(0.92), + }; + let json = serde_json::to_string(&seg).expect("serialize"); + let de: TranscriptSegment = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(seg.id, de.id); + assert_eq!(seg.start_ms, de.start_ms); + assert_eq!(seg.end_ms, de.end_ms); + assert_eq!(seg.text, de.text); + assert_eq!(seg.confidence, de.confidence); + } +} diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index dd9d330..c878e12 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -26,6 +26,11 @@ flume.workspace = true minijinja.workspace = true r2d2.workspace = true r2d2_sqlite.workspace = true +# WHY tokio-util in prod deps: TranscriptWriteCmd::Insert carries an +# Option threaded from the use-case so the writer +# can roll back mid-transaction if the caller cancels (closes the +# cancel-after-adapter-success window per the transcription v1 spec). +tokio-util.workspace = true [features] default = [] @@ -58,11 +63,12 @@ perima-app = { workspace = true } perima-fs = { path = "../fs" } perima-hash = { path = "../hash" } perima-media = { workspace = true } -# WHY tokio + tokio-util: ScanUseCase::execute is async; the rescan bench -# drives it via `Runtime::block_on` (one runtime per binary — this bench -# IS the binary, so no secondary-runtime violation). +# WHY tokio: ScanUseCase::execute is async; the rescan bench drives it +# via `Runtime::block_on` (one runtime per binary — this bench IS the +# binary, so no secondary-runtime violation). `tokio-util` is already a +# prod dep (CancellationToken in TranscriptWriteCmd::Insert) so it is +# automatically visible to integration tests + benches. tokio.workspace = true -tokio-util.workspace = true [[bench]] name = "fts" diff --git a/crates/db/migrations/V012__transcripts.sql b/crates/db/migrations/V012__transcripts.sql new file mode 100644 index 0000000..7c24a9d --- /dev/null +++ b/crates/db/migrations/V012__transcripts.sql @@ -0,0 +1,105 @@ +-- V012: transcripts schema (transcription v1 slice). +-- +-- Per CLAUDE.md "Schema ownership (dual, FTS5-scoped — Batch F)": +-- this migration owns the table + index DDL + the FTS5 virtual table + +-- non-FTS5 cascade trigger ONLY. The FTS5 maintenance triggers that +-- populate `transcript_search` from `transcript_segment` live in the +-- Rust+minijinja codegen at `crates/db/src/schema/`. +-- +-- See `docs/superpowers/specs/2026-05-02-transcription-v1-design.md` +-- § "Storage — dual-ownership per Batch F" for full rationale. + +-- Per-(file_uuid, backend) transcript header. Multiple rows per file +-- allowed so users can keep both fast-cloud and careful-local re-runs. +CREATE TABLE transcript ( + id TEXT PRIMARY KEY, -- UUIDv7 (lowercase hex) + file_uuid TEXT NOT NULL, -- FK to files.file_uuid (immutable + -- surrogate per V011); no CASCADE + backend TEXT NOT NULL, -- "groq:whisper-large-v3-turbo" + language TEXT, -- BCP-47 short, NULL = unknown + duration_ms INTEGER NOT NULL, + completed_at TEXT, -- ISO 8601; nullable so V013 + -- (resumability) can use NULL + -- for in-progress transcripts. + -- v1 always sets it (no transcript + -- row exists until success). + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + device_id TEXT NOT NULL, + hlc INTEGER NOT NULL, + deleted_at TEXT +); +CREATE INDEX ix_transcript_file_uuid ON transcript(file_uuid) WHERE deleted_at IS NULL; +CREATE INDEX ix_transcript_backend ON transcript(backend) WHERE deleted_at IS NULL; +-- Note: NO UNIQUE on (file_uuid, backend) — re-transcribing creates a new row; +-- "no UNIQUE on mutable columns" rule + "multiple transcripts per (media, backend)" +-- design intent. + +-- One row per segment. +CREATE TABLE transcript_segment ( + id TEXT PRIMARY KEY, -- UUIDv7 + transcript_id TEXT NOT NULL, -- FK to transcript.id; no CASCADE + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + text TEXT NOT NULL, + confidence REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + device_id TEXT NOT NULL, + hlc INTEGER NOT NULL, + deleted_at TEXT +); +CREATE INDEX ix_segment_transcript ON transcript_segment(transcript_id, start_ms) + WHERE deleted_at IS NULL; + +-- FTS5 virtual table is structural (DDL); lives in refinery, NOT codegen. +-- (Codegen owns the maintenance TRIGGERS that fan changes into this table.) +-- Tokenizer: unicode61 + remove_diacritics 2 — multilingual; explicitly NOT +-- `porter` (would silently produce wrong stems for non-English transcripts). +-- Tokenizer note: existing `search_index` (V006) uses default `unicode61` +-- (no remove_diacritics arg, which defaults to "1" — strips combining +-- diacritical marks but NOT all Unicode-normalized accent forms). +-- transcript_search uses `remove_diacritics 2` deliberately — transcripts +-- are pure spoken-content text where diacritic-insensitive matching helps +-- the most (a user typing "cafe" should find "café"); the existing +-- search_index covers filenames + paths where remove_diacritics 2 could +-- produce surprising matches across user-named files. Divergence is +-- intentional, not accidental. If consistency becomes desired, migrate +-- search_index to remove_diacritics 2 in its own slice with the +-- `INSERT INTO search_index(search_index) VALUES('rebuild')` ritual. +CREATE VIRTUAL TABLE transcript_search USING fts5( + text, + content='transcript_segment', + content_rowid='rowid', + tokenize = 'unicode61 remove_diacritics 2' +); + +-- Non-FTS5 trigger: cascade soft-delete + restore from transcript to its +-- segments. Lives in refinery (NOT a search-maintenance trigger; this +-- is row-version propagation across an FK relationship). +-- +-- WHY rename from spec-draft `trg_transcript_au_cascade` to +-- `transcript_segment_after_transcript_update_cascade`: matches the +-- codebase trigger-naming convention `_after__` +-- (e.g. `search_after_metadata_insert`). +CREATE TRIGGER transcript_segment_after_transcript_update_cascade +AFTER UPDATE ON transcript +BEGIN + -- DELETE arm + UPDATE transcript_segment + SET deleted_at = NEW.deleted_at, + updated_at = NEW.updated_at, + hlc = NEW.hlc + WHERE transcript_id = NEW.id + AND OLD.deleted_at IS NULL + AND NEW.deleted_at IS NOT NULL; + + -- RESTORE arm (V007→V008 bug class explicitly avoided) + UPDATE transcript_segment + SET deleted_at = NULL, + updated_at = NEW.updated_at, + hlc = NEW.hlc + WHERE transcript_id = NEW.id + AND OLD.deleted_at IS NOT NULL + AND NEW.deleted_at IS NULL; +END; diff --git a/crates/db/src/cmd.rs b/crates/db/src/cmd.rs index e6e4573..69d88a1 100644 --- a/crates/db/src/cmd.rs +++ b/crates/db/src/cmd.rs @@ -48,6 +48,8 @@ pub enum WriteCmd { /// WHY no `force` field: existence-check + pre-removal happens in /// `BackupDatabaseUseCase::execute`. The writer never sees `force`. Backup(BackupWriteCmd), + /// Transcript-repo writes (introduced by transcription v1 slice). + Transcript(TranscriptWriteCmd), /// Cooperative shutdown signal — when the writer thread receives /// this it exits its loop. Sent by `SqliteWriterHandle::join` and /// the `Drop` impl. @@ -78,6 +80,7 @@ impl WriteCmd { Self::Search(_) => "search", Self::Cache(c) => c.kind_str(), Self::Backup(_) => "backup", + Self::Transcript(_) => "transcript", Self::Shutdown => "shutdown", } } @@ -458,3 +461,46 @@ pub struct BackupWriteCmd { /// Reply channel; writer sends `Ok(size_bytes)` on success. pub reply: flume::Sender>, } + +/// Transcript-repo write commands. +/// +/// Introduced by the transcription v1 slice. See spec +/// `docs/superpowers/specs/2026-05-02-transcription-v1-design.md` +/// § "Writer-cmd handler shape". +#[derive(Debug)] +pub enum TranscriptWriteCmd { + /// Insert a fully-formed transcript + all segments atomically. + /// + /// Cancel-aware: if `cancel.is_cancelled()` after `BEGIN IMMEDIATE` + /// or before any per-segment INSERT, the writer rolls back without + /// writing anything and replies with + /// `Err(CoreError::Transcription(TranscriptionError::Cancelled))`. + /// This closes the cancel-after-adapter-success window between + /// the adapter returning `Ok` and the writer committing. + Insert { + /// The transcript header row to insert. + transcript: crate::transcript_repo::TranscriptRow, + /// All segments to insert in the same transaction. Each row's + /// `transcript_id` field is overridden by the writer with + /// `transcript.id` so callers don't have to plumb it manually. + segments: Vec, + /// Device ID stamped on every row (CRDT discipline). + device: String, + /// Optional cancel token threaded through from + /// `TranscribeRequest`. The writer handler checks this after + /// `BEGIN IMMEDIATE` and before each segment INSERT; on fire + /// it rolls back and returns `Cancelled`. + cancel: Option, + /// Per-request `UUIDv7` minted by the use-case. + /// + /// WHY threaded through to the writer: the post-COMMIT + /// `AppEvent::TranscriptionCompleted` carries `request_uuid` so the + /// frontend can correlate the event back to the in-flight job slot + /// it created from `TranscribeOutput::Started`. The writer is the + /// only place that knows when persistence succeeded, so it owns + /// the emit and therefore must receive `request_uuid` here. + request_uuid: String, + /// Reply channel for the inserted transcript's UUID. + reply: ReplyTx, + }, +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index d389ce0..c4cc1e5 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -14,6 +14,7 @@ pub mod pool; pub mod schema; pub mod search_repo; pub mod tag_repo; +pub mod transcript_repo; pub mod volume_repo; pub mod writer; @@ -27,6 +28,7 @@ pub use metadata_repo::SqliteMetadataRepository; pub use pool::ReadPool; pub use search_repo::SqliteSearchRepository; pub use tag_repo::SqliteTagRepository; +pub use transcript_repo::SqliteTranscriptRepository; pub use volume_repo::SqliteVolumeRepository; pub use writer::{SqliteWriter, SqliteWriterHandle}; diff --git a/crates/db/src/metadata_repo.rs b/crates/db/src/metadata_repo.rs index 4514b35..3b91e3f 100644 --- a/crates/db/src/metadata_repo.rs +++ b/crates/db/src/metadata_repo.rs @@ -252,6 +252,18 @@ impl MetadataRepository for SqliteMetadataRepository { // computed). Pre-V012 blake3_hash is NOT NULL (placeholder convention), // so `location.hash === null` never fires; equality comparison is the // only reliable signal. + // WHY join `volume_mounts` via an aggregated subquery: the desktop + // frontend needs `mount_path` to compute an absolute path for actions + // (e.g. transcribe → ffmpeg) that cannot resolve relative paths + // against the volume root. Joining the raw `volume_mounts` table + // would multiply rows when a volume has multiple mount records (e.g. + // remembered mount points across machines). The `MIN(mount_path)` + // subquery picks one deterministically per volume, mirroring the + // pattern in `lookup_by_file_uuid_sql`. `None` here means the + // volume is not currently mounted on any known machine — the + // frontend disables the dependent actions in that case (T9 review + // fix). Filter `deleted_at IS NULL` so soft-deleted mount rows do + // not surface as live mounts. let sql: &str = if vol_filter.is_some() { "SELECT f.file_uuid, f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, fl.status, fl.first_seen, @@ -259,12 +271,19 @@ impl MetadataRepository for SqliteMetadataRepository { fm.width, fm.height, fm.duration_ms, fm.captured_at, fm.camera_make, fm.camera_model, fm.codec, fm.bitrate_bps, fm.mime_type, fm.thumbnail_path, fm.thumbnail_status, - f.quick_hash + f.quick_hash, + vm.mount_path FROM file_locations fl JOIN files f ON f.blake3_hash = fl.blake3_hash LEFT JOIN file_metadata fm ON fm.blake3_hash = fl.blake3_hash AND fm.deleted_at IS NULL + LEFT JOIN ( + SELECT volume_id, MIN(mount_path) AS mount_path + FROM volume_mounts + WHERE deleted_at IS NULL + GROUP BY volume_id + ) vm ON vm.volume_id = fl.volume_id WHERE fl.deleted_at IS NULL AND fl.volume_id = ?1 ORDER BY fl.relative_path LIMIT ?2" @@ -275,12 +294,19 @@ impl MetadataRepository for SqliteMetadataRepository { fm.width, fm.height, fm.duration_ms, fm.captured_at, fm.camera_make, fm.camera_model, fm.codec, fm.bitrate_bps, fm.mime_type, fm.thumbnail_path, fm.thumbnail_status, - f.quick_hash + f.quick_hash, + vm.mount_path FROM file_locations fl JOIN files f ON f.blake3_hash = fl.blake3_hash LEFT JOIN file_metadata fm ON fm.blake3_hash = fl.blake3_hash AND fm.deleted_at IS NULL + LEFT JOIN ( + SELECT volume_id, MIN(mount_path) AS mount_path + FROM volume_mounts + WHERE deleted_at IS NULL + GROUP BY volume_id + ) vm ON vm.volume_id = fl.volume_id WHERE fl.deleted_at IS NULL ORDER BY fl.relative_path LIMIT ?1" @@ -316,6 +342,7 @@ impl MetadataRepository for SqliteMetadataRepository { let thumbnail_path: Option = row.get(17)?; let thumbnail_status: Option = row.get(18)?; let quick_hash: Option = row.get(19)?; + let mount_path: Option = row.get(20)?; Ok(( file_uuid_str, hash_hex, @@ -337,6 +364,7 @@ impl MetadataRepository for SqliteMetadataRepository { thumbnail_path, thumbnail_status, quick_hash, + mount_path, )) }) .map_err(Error::from)?; @@ -364,6 +392,7 @@ impl MetadataRepository for SqliteMetadataRepository { thumbnail_path, thumbnail_status, quick_hash, + mount_path, ) = row.map_err(Error::from)?; let file_uuid = crate::file_repo::parse_file_uuid(&file_uuid_str)?; let hash = crate::file_repo::parse_optional_hash(hash_hex_opt.as_deref())?; @@ -414,7 +443,7 @@ impl MetadataRepository for SqliteMetadataRepository { }) }; - out.push((location, metadata, quick_hash)); + out.push((location, metadata, quick_hash, mount_path)); } Ok(out) } @@ -678,13 +707,22 @@ mod tests { // Assert: one row, metadata is None. assert_eq!(rows.len(), 1, "expected exactly one (file_loc, meta) pair"); - let (loc, meta, _quick_hash) = &rows[0]; + let (loc, meta, _quick_hash, mount_path) = &rows[0]; assert_eq!(loc.hash, Some(f.hash)); assert_eq!(loc.relative_path.as_str(), "no_meta.txt"); assert!( meta.is_none(), "file without file_metadata row must yield None" ); + // WHY mount_path is None here: this fixture upserts a file_location + // for a fresh `VolumeId::new()` without registering a mount via + // `volume_repo::upsert_mount`. The LEFT JOIN therefore yields NULL, + // matching the "volume not currently mounted" frontend signal that + // gates absolute-path-dependent actions (T9 review fix). + assert!( + mount_path.is_none(), + "no volume_mounts row → mount_path must be None", + ); } #[test] @@ -715,7 +753,7 @@ mod tests { // Assert assert_eq!(rows.len(), 1); - let (loc, got_meta, _quick_hash) = &rows[0]; + let (loc, got_meta, _quick_hash, _mount_path) = &rows[0]; assert_eq!(loc.hash, Some(f.hash)); let got = got_meta .as_ref() @@ -736,6 +774,55 @@ mod tests { ); } + /// T9 review fix: rows from a volume with a registered mount must + /// surface `mount_path = Some(...)` so the desktop frontend can + /// materialise an absolute path. Soft-deleted mount rows must not + /// leak through (they would falsely re-enable disabled actions). + #[test] + fn list_with_metadata_surfaces_mount_path_when_volume_is_mounted() { + use crate::volume_repo::SqliteVolumeRepository; + use perima_core::VolumeRepository as _; + + let (td, repo, writer) = metadata_repo(); + let db_path = td.path().join("test.db"); + let dev = device(); + let vol = VolumeId::new(); + let f = sample_hashed_file(b"mounted", "with_mount.txt"); + + // Seed file + location. + { + let seed_reads = ReadPool::open(&db_path).expect("seed pool"); + let file_repo = SqliteFileRepository::new(writer.sender(), seed_reads); + file_repo.upsert_file(&f, dev).expect("upsert file"); + file_repo + .upsert_location(&f.hash, vol, &f.discovered.relative_path, dev) + .expect("upsert location"); + } + + // Register a mount for the volume on this device. + { + let mount_reads = ReadPool::open(&db_path).expect("mount pool"); + let vol_repo = SqliteVolumeRepository::new(writer.sender(), mount_reads); + vol_repo + .record_mount(vol, dev, std::path::Path::new("/mnt/perima-test")) + .expect("record_mount"); + } + + // Act. + let rows = repo + .list_with_metadata(100, None) + .expect("list_with_metadata"); + + // Assert: mount_path threaded through. + assert_eq!(rows.len(), 1, "expected exactly one row"); + let (_loc, _meta, _quick_hash, mount_path) = &rows[0]; + assert_eq!( + mount_path.as_deref(), + Some("/mnt/perima-test"), + "mount_path must reflect the registered volume_mounts row", + ); + } + #[test] fn update_thumbnail_marks_ready_with_path() { let (_td, repo, _writer) = metadata_repo(); diff --git a/crates/db/src/schema/mod.rs b/crates/db/src/schema/mod.rs index 7d094d9..d55d137 100644 --- a/crates/db/src/schema/mod.rs +++ b/crates/db/src/schema/mod.rs @@ -1,11 +1,14 @@ -//! FTS5 trigger codegen — single source of truth for the 16 sync triggers. +//! FTS5 trigger codegen — single source of truth for the 18 sync triggers. //! //! See `docs/superpowers/specs/2026-04-23-arch-audit-batch-F-fts-codegen-design.md` //! for the design rationale + the V006→V007→V008 bug class this closes. +//! Transcription v1 (`docs/superpowers/specs/2026-05-02-transcription-v1-design.md`) +//! added 3 `transcript_search` maintenance triggers on top of the post-Task-3 +//! baseline of 15. //! //! Public surface: //! - [`spec::FtsAggregation`] — one trigger entry. -//! - [`spec::FTS_AGGREGATIONS`] — the 16 entries. +//! - [`spec::FTS_AGGREGATIONS`] — the 18 entries. //! - [`spec::LEGACY_TRIGGER_NAMES`] — historical names dropped but no longer created. //! - [`render_fts_triggers`] — render the install body to a `String`. //! - [`install_fts_triggers`] — execute the rendered SQL on a `Connection`. @@ -110,6 +113,9 @@ const fn body_kind_name(b: spec::BodyKind) -> &'static str { spec::BodyKind::TagsNameUpdate => "TagsNameUpdate", spec::BodyKind::TagsSoftDeleteOrRestore => "TagsSoftDeleteOrRestore", spec::BodyKind::TagsDelete => "TagsDelete", + spec::BodyKind::TranscriptSegmentAfterInsert => "TranscriptSegmentAfterInsert", + spec::BodyKind::TranscriptSegmentAfterDelete => "TranscriptSegmentAfterDelete", + spec::BodyKind::TranscriptSegmentAfterUpdate => "TranscriptSegmentAfterUpdate", } } @@ -161,6 +167,14 @@ mod tests { insta::assert_snapshot!("fts_tags", render_for_source("tags")); } + #[test] + fn snapshot_transcript_segment_triggers() { + insta::assert_snapshot!( + "fts_transcript_segment", + render_for_source("transcript_segment") + ); + } + #[test] fn render_includes_all_legacy_drops() { let sql = render_fts_triggers(); diff --git a/crates/db/src/schema/snapshots/perima_db__schema__tests__fts_transcript_segment.snap b/crates/db/src/schema/snapshots/perima_db__schema__tests__fts_transcript_segment.snap new file mode 100644 index 0000000..7b90851 --- /dev/null +++ b/crates/db/src/schema/snapshots/perima_db__schema__tests__fts_transcript_segment.snap @@ -0,0 +1,126 @@ +--- +source: crates/db/src/schema/mod.rs +expression: "render_for_source(\"transcript_segment\")" +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +DROP TRIGGER IF EXISTS transcript_search_after_segment_insert; +DROP TRIGGER IF EXISTS transcript_search_after_segment_delete; +DROP TRIGGER IF EXISTS transcript_search_after_segment_update; + + + + + +CREATE TRIGGER transcript_search_after_segment_insert +AFTER INSERT ON transcript_segment +BEGIN +INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE NEW.deleted_at IS NULL; +END; + + +CREATE TRIGGER transcript_search_after_segment_delete +AFTER DELETE ON transcript_segment +BEGIN +INSERT INTO transcript_search(transcript_search, rowid, text) + VALUES ('delete', OLD.rowid, OLD.text); +END; + + +CREATE TRIGGER transcript_search_after_segment_update +AFTER UPDATE ON transcript_segment +BEGIN +-- DELETE arm: row became soft-deleted + INSERT INTO transcript_search(transcript_search, rowid, text) + SELECT 'delete', OLD.rowid, OLD.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL; + + -- RESTORE arm: row went from deleted back to live + INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL; + + -- TEXT-CHANGED arm: row stayed live but text edited + INSERT INTO transcript_search(transcript_search, rowid, text) + SELECT 'delete', OLD.rowid, OLD.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NULL AND OLD.text <> NEW.text; + INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NULL AND OLD.text <> NEW.text; +END; diff --git a/crates/db/src/schema/spec.rs b/crates/db/src/schema/spec.rs index 2fc00e6..8d28348 100644 --- a/crates/db/src/schema/spec.rs +++ b/crates/db/src/schema/spec.rs @@ -55,6 +55,15 @@ pub enum BodyKind { TagsSoftDeleteOrRestore, /// `search_after_tags_delete` — refresh tags agg for every holder (post-DELETE OLD.id). TagsDelete, + /// `transcript_search_after_segment_insert` — populate `transcript_search` on new live segment. + TranscriptSegmentAfterInsert, + /// `transcript_search_after_segment_delete` — remove from `transcript_search` on hard delete. + TranscriptSegmentAfterDelete, + /// `transcript_search_after_segment_update` — soft-delete / restore / text-edit + /// arms for `transcript_search` maintenance. WHEN gates live INSIDE the body + /// macro (per-arm SELECT...WHERE) rather than on the outer trigger so that + /// one trigger can cover all three transitions. + TranscriptSegmentAfterUpdate, } /// One FTS-trigger spec entry. Drives the template render loop. @@ -89,11 +98,13 @@ pub const LEGACY_TRIGGER_NAMES: &[&str] = &[ "search_after_location_hash_change_retire", ]; -/// The 15 trigger entries the codegen renders. +/// The 18 trigger entries the codegen renders. /// /// Order matters for fire-order on combined-transaction UPDATE statements /// (`SQLite` fires triggers in CREATE order). See spec §7.1 + V007 inline -/// comment "fire-order: 2a, 2b, 2c". +/// comment "fire-order: 2a, 2b, 2c". Transcription v1 added the final +/// 3 (`transcript_search_after_segment_*`) on top of the post-Task-3 +/// post-pivot baseline of 15. pub const FTS_AGGREGATIONS: &[FtsAggregation] = &[ // search_content → search_index sync (V007). FtsAggregation { @@ -214,6 +225,34 @@ pub const FTS_AGGREGATIONS: &[FtsAggregation] = &[ when: None, body: BodyKind::TagsDelete, }, + // transcript_segment → transcript_search FTS5 maintenance triggers + // (transcription v1 slice). WHEN gates live INSIDE the body macros + // (SELECT...WHERE) rather than on the outer trigger, so one + // AFTER UPDATE trigger covers soft-delete + restore + text-changed + // arms in a single place. See spec + // `docs/superpowers/specs/2026-05-02-transcription-v1-design.md` + // § "Codegen: FTS5 maintenance triggers". + FtsAggregation { + name: "transcript_search_after_segment_insert", + source_table: "transcript_segment", + event: TriggerEvent::Insert, + when: None, + body: BodyKind::TranscriptSegmentAfterInsert, + }, + FtsAggregation { + name: "transcript_search_after_segment_delete", + source_table: "transcript_segment", + event: TriggerEvent::Delete, + when: None, + body: BodyKind::TranscriptSegmentAfterDelete, + }, + FtsAggregation { + name: "transcript_search_after_segment_update", + source_table: "transcript_segment", + event: TriggerEvent::Update, + when: None, + body: BodyKind::TranscriptSegmentAfterUpdate, + }, ]; #[cfg(test)] @@ -221,14 +260,16 @@ mod tests { use super::*; #[test] - fn fts_aggregations_has_fifteen_entries() { + fn fts_aggregations_has_eighteen_entries() { // Post-Task-3 pivot (spec §4.1.4): retire trigger is gone (file_uuid // is stable across hash changes, so the OLD-hash search_content row // doesn't need retiring). 16 → 15. + // Transcription v1 (spec 2026-05-02): +3 entries for + // transcript_segment → transcript_search FTS5 maintenance. 15 → 18. assert_eq!( FTS_AGGREGATIONS.len(), - 15, - "expected 15 trigger entries post-Task-3 — see spec §4.1.4" + 18, + "expected 18 trigger entries post-transcription-v1 — see spec §4.1.4 + 2026-05-02-transcription-v1" ); } diff --git a/crates/db/src/schema/templates/fts_triggers.sql.j2 b/crates/db/src/schema/templates/fts_triggers.sql.j2 index e627b75..3e300bd 100644 --- a/crates/db/src/schema/templates/fts_triggers.sql.j2 +++ b/crates/db/src/schema/templates/fts_triggers.sql.j2 @@ -280,6 +280,43 @@ UPDATE search_content {{ refresh_tags_for_holders('OLD.id') }} {%- endmacro %} +{# transcript_segment -> transcript_search FTS5 maintenance (transcription v1). + The three macros below mirror the V008 deleted_at-aware shape used for + search_index, with WHEN gates inlined as SELECT...WHERE inside the body + so that ONE AFTER UPDATE trigger covers soft-delete + restore + text-edit. + See spec docs/superpowers/specs/2026-05-02-transcription-v1-design.md. #} + +{% macro body_transcript_segment_after_insert() -%} + INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE NEW.deleted_at IS NULL; +{%- endmacro %} + +{% macro body_transcript_segment_after_delete() -%} + INSERT INTO transcript_search(transcript_search, rowid, text) + VALUES ('delete', OLD.rowid, OLD.text); +{%- endmacro %} + +{% macro body_transcript_segment_after_update() -%} + -- DELETE arm: row became soft-deleted + INSERT INTO transcript_search(transcript_search, rowid, text) + SELECT 'delete', OLD.rowid, OLD.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL; + + -- RESTORE arm: row went from deleted back to live + INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL; + + -- TEXT-CHANGED arm: row stayed live but text edited + INSERT INTO transcript_search(transcript_search, rowid, text) + SELECT 'delete', OLD.rowid, OLD.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NULL AND OLD.text <> NEW.text; + INSERT INTO transcript_search(rowid, text) + SELECT NEW.rowid, NEW.text + WHERE OLD.deleted_at IS NULL AND NEW.deleted_at IS NULL AND OLD.text <> NEW.text; +{%- endmacro %} + {# === Prologue: drop legacy + current names (idempotent) =================== #} {% for name in legacy_trigger_names -%} @@ -336,6 +373,12 @@ BEGIN {{ body_tags_soft_delete_or_restore() }} {%- elif agg.body == "TagsDelete" %} {{ body_tags_delete() }} +{%- elif agg.body == "TranscriptSegmentAfterInsert" %} +{{ body_transcript_segment_after_insert() }} +{%- elif agg.body == "TranscriptSegmentAfterDelete" %} +{{ body_transcript_segment_after_delete() }} +{%- elif agg.body == "TranscriptSegmentAfterUpdate" %} +{{ body_transcript_segment_after_update() }} {%- endif %} END; diff --git a/crates/db/src/transcript_repo.rs b/crates/db/src/transcript_repo.rs new file mode 100644 index 0000000..313ded1 --- /dev/null +++ b/crates/db/src/transcript_repo.rs @@ -0,0 +1,174 @@ +//! `SQLite` adapter for transcripts. Owns the writer-cmd reply types, +//! row newtypes, and the `SqliteTranscriptRepository` reads-side. +//! +//! The matching writer-side handler lives at +//! `crates/db/src/writer/transcript.rs` (a private writer-actor module +//! whose entry point is invoked from the dispatch loop in +//! [`crate::writer::SqliteWriter`]). +//! +//! Introduced by the transcription v1 slice — see +//! `docs/superpowers/specs/2026-05-02-transcription-v1-design.md`. + +use flume::Sender; +use uuid::Uuid; + +use perima_core::CoreError; +use perima_core::transcription::TranscriptSegment; + +use crate::cmd::{ReplyTx, TranscriptWriteCmd, WriteCmd}; +use crate::pool::ReadPool; + +/// Transcript ID newtype. The inner `String` is the `UUIDv7`'s +/// lowercase-hex `simple` form, matching the existing `Tag::id`, +/// `Volume::volume_id` conventions. +/// +/// WHY no `serde` / `specta` derives on this side: the type is db-adapter +/// internal in the transcription v1 slice. T7 (Tauri commands) will mint +/// a separate IPC-side newtype derived from `perima_core` that carries +/// `serde::Serialize + specta::Type` once the read-path is wired. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TranscriptId( + /// Lowercase-hex `UUIDv7`. + pub String, +); + +impl TranscriptId { + /// Generate a fresh `UUIDv7` transcript ID. + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7().simple().to_string()) + } +} + +impl Default for TranscriptId { + fn default() -> Self { + Self::new() + } +} + +/// Row payload for inserting a `transcript` row. The writer stamps +/// `created_at`, `updated_at`, `hlc`; the caller provides everything else. +#[derive(Debug, Clone)] +pub struct TranscriptRow { + /// `UUIDv7` (lowercase hex string). + pub id: TranscriptId, + /// FK to `files.file_uuid` (immutable surrogate per V011). + pub file_uuid: String, + /// Backend identifier in `provider:model` form + /// (e.g. `groq:whisper-large-v3-turbo`). + pub backend: String, + /// Detected language (BCP-47 short code or `None`). + pub language: Option, + /// Total duration of the source media in milliseconds. + pub duration_ms: u32, +} + +/// Row payload for inserting a `transcript_segment` row. +// WHY id: TranscriptId (not a separate TranscriptSegmentId): same UUIDv7-hex +// shape as the parent's id, single newtype keeps the SQL boundary simple. A +// future slice may split into a dedicated `TranscriptSegmentId` newtype if the +// type-confusion at construction sites starts biting. +#[derive(Debug, Clone)] +pub struct TranscriptSegmentRow { + /// `UUIDv7`. + pub id: TranscriptId, + /// FK to `transcript.id`. The writer overrides this with the + /// canonical parent transcript id from + /// [`TranscriptWriteCmd::Insert::transcript`] before binding, so a + /// caller that forgets to set it (e.g. when constructing via + /// [`From`]) still produces consistent rows. + pub transcript_id: TranscriptId, + /// Start time in milliseconds since file start. + pub start_ms: u32, + /// End time in milliseconds since file start. + pub end_ms: u32, + /// Transcribed text. + pub text: String, + /// Confidence score `[0.0, 1.0]` if the backend exposes it. + pub confidence: Option, +} + +impl From for TranscriptSegmentRow { + /// Convert a domain segment into a row, leaving `transcript_id` + /// empty for the writer to populate from the canonical parent. + fn from(seg: TranscriptSegment) -> Self { + Self { + id: TranscriptId(seg.id.simple().to_string()), + // transcript_id is overridden by the writer at INSERT time. + transcript_id: TranscriptId(String::new()), + start_ms: seg.start_ms, + end_ms: seg.end_ms, + text: seg.text, + confidence: seg.confidence, + } + } +} + +/// Reads-side adapter for transcripts. +/// +/// Mirrors the post-Batch-C `Sqlite*Repository` shape: +/// `(flume::Sender, ReadPool)`, both cheap to clone. Writes +/// build a [`TranscriptWriteCmd`] variant with a `flume::bounded(1)` +/// reply channel and block on the reply. +#[derive(Clone)] +pub struct SqliteTranscriptRepository { + writer: Sender, + #[allow(dead_code)] + // WHY: reads-side queries land in T7 (Tauri commands); kept now to mirror sibling repo shape. + reads: ReadPool, +} + +impl std::fmt::Debug for SqliteTranscriptRepository { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SqliteTranscriptRepository") + .finish_non_exhaustive() + } +} + +impl SqliteTranscriptRepository { + /// Construct an adapter from a writer-command sender + a read pool. + /// + /// Migrations have already run inside [`crate::SqliteWriter::start`] + /// before the read pool opens (spec §3.6). + #[must_use] + pub const fn new(writer: Sender, reads: ReadPool) -> Self { + Self { writer, reads } + } + + /// Insert a transcript + segments atomically via the writer. + /// + /// One HLC value per command stamped on every row (per Batch C + /// "one HLC per user-visible logical event"). The writer uses + /// `request_uuid` to populate `AppEvent::TranscriptionCompleted`'s + /// correlation field after a successful `COMMIT`. + /// + /// # Errors + /// Returns [`CoreError::Transcription`] (`Cancelled`) if `cancel` + /// fires before or during the writer's transaction; returns + /// `CoreError::Internal` for I/O / channel failures and rusqlite + /// errors propagated as `Internal` from the writer. + pub fn insert_with_request_uuid( + &self, + transcript: TranscriptRow, + segments: Vec, + device: String, + cancel: Option, + request_uuid: String, + ) -> Result { + let (reply_tx, reply_rx): (ReplyTx, _) = flume::bounded(1); + let cmd = WriteCmd::Transcript(TranscriptWriteCmd::Insert { + transcript, + segments, + device, + cancel, + request_uuid, + reply: reply_tx, + }); + self.writer + .send(cmd) + .map_err(|_| CoreError::Internal("transcript writer disconnected".into()))?; + reply_rx + .recv() + .map_err(|_| CoreError::Internal("transcript writer reply lost".into()))? + } +} diff --git a/crates/db/src/writer/mod.rs b/crates/db/src/writer/mod.rs index df7e52c..ff6632c 100644 --- a/crates/db/src/writer/mod.rs +++ b/crates/db/src/writer/mod.rs @@ -58,6 +58,7 @@ mod file; mod metadata; mod search; mod tag; +mod transcript; mod volume; /// Handle returned by [`SqliteWriter::start`]. @@ -274,6 +275,7 @@ fn dispatch(conn: &mut Connection, cmd: WriteCmd, bus: &Arc) { WriteCmd::Search(c) => search::handle(conn, c, bus), WriteCmd::Cache(c) => cache::handle(conn, c, bus), WriteCmd::Backup(cmd) => backup::handle(conn, cmd), + WriteCmd::Transcript(c) => transcript::handle(conn, c, bus), // WHY unreachable: Shutdown is short-circuited in // `run_writer_loop` BEFORE this dispatch is invoked. Reaching // here means the loop ordering changed without updating diff --git a/crates/db/src/writer/transcript.rs b/crates/db/src/writer/transcript.rs new file mode 100644 index 0000000..09abf96 --- /dev/null +++ b/crates/db/src/writer/transcript.rs @@ -0,0 +1,196 @@ +//! Writer-side handler for [`crate::cmd::TranscriptWriteCmd`]. Mirrors +//! the [`crate::writer::metadata`] handler shape: +//! `(conn: &mut Connection, cmd: TranscriptWriteCmd, bus: &Arc)`. +//! +//! Shape: `BEGIN IMMEDIATE` → cancel-aware insert (header + N segments, +//! one HLC value per command) → `COMMIT` → reply → emit. FTS5 maintenance +//! triggers fire automatically inside the transaction; see codegen +//! template macros `body_transcript_segment_after_*`. +//! +//! On successful COMMIT this handler emits TWO events: +//! 1. [`AppEvent::TranscriptionCompleted`] — surfaces the use-case's +//! `request_uuid` plus the freshly-persisted transcript id, segment +//! count, language, and file id. Frontend uses this to dismiss the +//! in-flight job slot and refetch the transcript row. +//! 2. [`AppEvent::IndexInvalidated`] with +//! [`InvalidationReason::SearchIndexRebuilt`] — keeps existing FTS +//! consumers refreshing without a new variant. +//! +//! # Cancellation race +//! +//! The cancel token is the same one passed into `TranscribeRequest`; the +//! use-case threads it through unchanged. Checking inside the writer +//! transaction (after `BEGIN IMMEDIATE`, before each per-segment INSERT) +//! closes the cancel-after-adapter-success window between the +//! transcriber adapter returning `Ok` and the writer committing. + +use std::sync::Arc; + +use rusqlite::Connection; + +use perima_core::transcription::TranscriptionError; +use perima_core::{AppEvent, CoreError, EventBus, Hlc, InvalidationReason}; + +use crate::cmd::TranscriptWriteCmd; +use crate::errors::Error; +use crate::transcript_repo::{TranscriptId, TranscriptRow, TranscriptSegmentRow}; + +/// Writer-side dispatch for [`TranscriptWriteCmd`]. Consumes the command +/// (the reply channel lives inside each variant) and sends the result +/// back on the caller's reply channel. +/// +/// After a successful `COMMIT`, emits +/// [`AppEvent::TranscriptionCompleted`] (with the use-case's +/// `request_uuid`) followed by +/// [`AppEvent::IndexInvalidated`] with +/// [`InvalidationReason::SearchIndexRebuilt`]. +#[allow(clippy::needless_pass_by_value)] +pub(super) fn handle(conn: &mut Connection, cmd: TranscriptWriteCmd, bus: &Arc) { + match cmd { + TranscriptWriteCmd::Insert { + transcript, + segments, + device, + cancel, + request_uuid, + reply, + } => { + let result = insert_impl( + conn, + &transcript, + &segments, + &device, + cancel.as_ref(), + &request_uuid, + ); + let send_id = match &result { + Ok((id, _events)) => Ok(id.clone()), + Err(e) => Err(e.clone()), + }; + // Send reply first; even if events fail to emit, the writer-cmd is settled. + if reply.send(send_id).is_err() { + tracing::debug!("transcript insert reply channel closed before send"); + } + // Emit events after the COMMIT, only on success. + if let Ok((_id, events)) = result { + for event in &events { + if let Err(e) = bus.emit(event) { + tracing::warn!(?e, "post-commit emit failed for transcript insert"); + } + } + } + } + } +} + +/// Inner helper: opens `BEGIN IMMEDIATE`, INSERTs the transcript header, +/// loops over segments with cancel checks, COMMITs, and returns the +/// transcript id + events to emit. +fn insert_impl( + conn: &mut Connection, + transcript: &TranscriptRow, + segments: &[TranscriptSegmentRow], + device: &str, + cancel: Option<&tokio_util::sync::CancellationToken>, + request_uuid: &str, +) -> Result<(TranscriptId, Vec), CoreError> { + // WHY BEGIN IMMEDIATE: writes always grab the WRITE lock at + // statement-start to avoid a SHARED→RESERVED upgrade race under + // WAL. Consistent with every other write path in this crate. + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(Error::from)?; + + // Post-BEGIN cancel check. Closes the cancel-after-adapter-success + // window — the adapter may have returned Ok before the use-case + // observed a downstream cancel; we honour that cancel here even + // though we hold the writer transaction. + if let Some(token) = cancel + && token.is_cancelled() + { + // Drop the transaction without commit; rusqlite's Transaction + // Drop is documented to roll back when the tx is unconsumed. + drop(tx); + return Err(CoreError::Transcription(TranscriptionError::Cancelled)); + } + + // One HLC value per command — same value stamped on every row + // (spec §3.7 / Batch C "one HLC per user-visible logical event"). + let hlc = Hlc::now().pack(); + let now = chrono::Utc::now().to_rfc3339(); + + tx.execute( + "INSERT INTO transcript + (id, file_uuid, backend, language, duration_ms, + completed_at, created_at, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6, ?7, ?8)", + rusqlite::params![ + &transcript.id.0, + &transcript.file_uuid, + &transcript.backend, + &transcript.language, + transcript.duration_ms, + &now, + device, + hlc, + ], + ) + .map_err(Error::from)?; + + for seg in segments { + if let Some(token) = cancel + && token.is_cancelled() + { + drop(tx); + return Err(CoreError::Transcription(TranscriptionError::Cancelled)); + } + tx.execute( + "INSERT INTO transcript_segment + (id, transcript_id, start_ms, end_ms, text, confidence, + created_at, updated_at, device_id, hlc) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, ?9)", + rusqlite::params![ + &seg.id.0, + // Override seg.transcript_id with the canonical parent id — + // domain `From` cannot know the eventual + // transcript header id; the writer is the canonical source. + &transcript.id.0, + seg.start_ms, + seg.end_ms, + &seg.text, + seg.confidence, + &now, + device, + hlc, + ], + ) + .map_err(Error::from)?; + } + + tx.commit().map_err(Error::from)?; + + // WHY two events: TranscriptionCompleted carries the use-case's + // `request_uuid` so the frontend can dismiss the in-flight slot it + // created from `TranscribeOutput::Started`; SearchIndexRebuilt keeps + // the existing FTS-consumer refresh path working without a new + // discriminator. + // + // WHY u32 cast for segment_count: the frontend payload field is u32 + // (matches the AppEvent variant); transcripts in v1 cap segments at + // far less than u32::MAX so the saturating cast is safe. + let segment_count = u32::try_from(segments.len()).unwrap_or(u32::MAX); + let events = vec![ + AppEvent::TranscriptionCompleted { + request_uuid: request_uuid.to_owned(), + transcript_id: transcript.id.0.clone(), + file_uuid: transcript.file_uuid.clone(), + segment_count, + language: transcript.language.clone(), + }, + AppEvent::IndexInvalidated { + reason: InvalidationReason::SearchIndexRebuilt, + }, + ]; + + Ok((transcript.id.clone(), events)) +} diff --git a/crates/db/tests/fts_install_idempotent.rs b/crates/db/tests/fts_install_idempotent.rs index 5ba3226..4a440df 100644 --- a/crates/db/tests/fts_install_idempotent.rs +++ b/crates/db/tests/fts_install_idempotent.rs @@ -8,9 +8,14 @@ use perima_db::schema::install_fts_triggers; use perima_db::{open_and_migrate, schema::FTS_AGGREGATIONS}; fn fts_triggers(conn: &rusqlite::Connection) -> Vec<(String, String)> { + // WHY include `transcript_search_after_%`: transcription v1 + // (2026-05-02 spec) added 3 maintenance triggers under that prefix + // alongside the existing `sc_*` + `search_after_%` set. Missing the + // glob would have under-counted post-bump. conn.prepare( "SELECT name, sql FROM sqlite_master \ - WHERE type='trigger' AND (name LIKE 'sc_%' OR name LIKE 'search_after_%') \ + WHERE type='trigger' AND (name LIKE 'sc_%' OR name LIKE 'search_after_%' \ + OR name LIKE 'transcript_search_after_%') \ ORDER BY name", ) .unwrap() diff --git a/crates/db/tests/transcript_proptests.rs b/crates/db/tests/transcript_proptests.rs new file mode 100644 index 0000000..65dd88d --- /dev/null +++ b/crates/db/tests/transcript_proptests.rs @@ -0,0 +1,364 @@ +//! FTS5 ground-truth proptest for the `transcript_search` virtual table. +//! +//! Mirrors `crates/db/tests/search_proptests.rs::fts_matches_ground_truth_under_soft_delete_churn` +//! shape: a small randomized op universe runs against a fresh tempfile DB +//! per case, and after EVERY op the contents of `transcript_search` +//! (incrementally maintained by codegen-installed FTS5 triggers) must +//! equal a ground-truth set computed directly from joined live state. +//! +//! Cases capped at 64 per CLAUDE.md "FTS5 proptests" rule (#124). +//! +//! Seeding uses a single raw `seed_conn` (writer is idle on the flume +//! channel); follows the same per-case writer + per-case raw-conn pattern +//! the sibling proptest uses. + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use rusqlite::{Connection, OpenFlags, params}; +use tempfile::TempDir; + +use perima_core::EventBus; +use perima_db::test_utils::NoopBus; +use perima_db::writer::{SqliteWriter, SqliteWriterHandle}; + +// --------------------------------------------------------------------------- +// Proptest universe constants +// --------------------------------------------------------------------------- + +const TRANSCRIPT_COUNT: usize = 2; +const SEGMENT_TEXTS: &[&str] = &[ + "alpha bravo", + "charlie delta", + "echo foxtrot", + "golf hotel", + "india juliet", +]; +const PROP_DEV: &str = "prop-dev"; +const PROP_TS: &str = "2026-01-01T00:00:00Z"; +const PROP_HLC: i64 = 1; + +// --------------------------------------------------------------------------- +// Setup helpers (test-binary-local; no shared common/ surface added) +// --------------------------------------------------------------------------- + +fn scratch_writer() -> (TempDir, std::path::PathBuf, SqliteWriterHandle) { + let td = tempfile::tempdir().expect("tempdir"); + let db_path = td.path().join("transcripts.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + (td, db_path, writer) +} + +#[allow(clippy::disallowed_methods)] // WHY: proptest seeding via raw conn — see GH #131 + #124 (sibling search_proptests pattern). +fn seed_conn(db_path: &std::path::Path) -> Connection { + Connection::open(db_path).expect("seed conn open") +} + +fn ro_conn(db_path: &std::path::Path) -> Connection { + Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("ro conn open") +} + +/// Seed `TRANSCRIPT_COUNT` parent transcript rows. Returns their ids. +fn seed_transcripts(conn: &Connection) -> Vec { + let mut ids = Vec::with_capacity(TRANSCRIPT_COUNT); + for i in 0..TRANSCRIPT_COUNT { + let id = format!("t{i:063}"); // deterministic 64-char id + conn.execute( + "INSERT INTO transcript + (id, file_uuid, backend, language, duration_ms, + completed_at, created_at, updated_at, device_id, hlc) + VALUES (?1, ?2, 'groq:test', 'en', 1000, + ?3, ?3, ?3, ?4, ?5)", + params![&id, format!("file-{i}"), PROP_TS, PROP_DEV, PROP_HLC], + ) + .expect("seed transcript"); + ids.push(id); + } + ids +} + +fn insert_segment(conn: &Connection, seg_id: &str, transcript_id: &str, text: &str) { + conn.execute( + "INSERT INTO transcript_segment + (id, transcript_id, start_ms, end_ms, text, confidence, + created_at, updated_at, device_id, hlc) + VALUES (?1, ?2, 0, 1000, ?3, NULL, ?4, ?4, ?5, ?6)", + params![seg_id, transcript_id, text, PROP_TS, PROP_DEV, PROP_HLC], + ) + .expect("insert segment"); +} + +fn soft_delete_segment(conn: &Connection, seg_id: &str) { + conn.execute( + "UPDATE transcript_segment + SET deleted_at = ?1, updated_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL", + params![PROP_TS, seg_id], + ) + .expect("soft delete segment"); +} + +fn restore_segment(conn: &Connection, seg_id: &str) { + conn.execute( + "UPDATE transcript_segment + SET deleted_at = NULL, updated_at = ?1 + WHERE id = ?2 AND deleted_at IS NOT NULL", + params![PROP_TS, seg_id], + ) + .expect("restore segment"); +} + +fn soft_delete_transcript(conn: &Connection, transcript_id: &str) { + conn.execute( + "UPDATE transcript + SET deleted_at = ?1, updated_at = ?1, hlc = ?2 + WHERE id = ?3 AND deleted_at IS NULL", + params![PROP_TS, PROP_HLC, transcript_id], + ) + .expect("soft delete transcript"); +} + +fn restore_transcript(conn: &Connection, transcript_id: &str) { + conn.execute( + "UPDATE transcript + SET deleted_at = NULL, updated_at = ?1, hlc = ?2 + WHERE id = ?3 AND deleted_at IS NOT NULL", + params![PROP_TS, PROP_HLC, transcript_id], + ) + .expect("restore transcript"); +} + +fn edit_segment_text(conn: &Connection, seg_id: &str, new_text: &str) { + conn.execute( + "UPDATE transcript_segment + SET text = ?1, updated_at = ?2 + WHERE id = ?3", + params![new_text, PROP_TS, seg_id], + ) + .expect("edit segment text"); +} + +// --------------------------------------------------------------------------- +// Ground-truth derivation +// --------------------------------------------------------------------------- + +/// `(rowid, text)` pairs that SHOULD currently be present in +/// `transcript_search`. Computed directly from joined live state. +fn ground_truth(conn: &Connection) -> BTreeSet<(i64, String)> { + let mut stmt = conn + .prepare( + "SELECT s.rowid, s.text + FROM transcript_segment s + WHERE s.deleted_at IS NULL", + ) + .expect("prepare ground truth"); + stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) + .expect("query ground truth") + .filter_map(Result::ok) + .collect() +} + +/// Read `transcript_search` content. The FTS5 contentless-shadow tables +/// don't expose a clean `SELECT * FROM transcript_search`; instead we +/// query the join with the source table by rowid where the FTS index +/// MATCH-shaped lookup confirms presence. Simpler: enumerate all +/// `transcript_search` rowids via an FTS-internal query. +/// +/// WHY this approach: `transcript_search` uses `content='transcript_segment'`, +/// meaning rows in the FTS5 index are keyed by `transcript_segment.rowid` +/// and the index stores the tokens of `text`. We fan from each segment's +/// rowid and probe whether a query matching ANY token of the original text +/// returns the row. False negatives here (a row's tokens fail to MATCH any +/// of their own words) would indicate a real index inconsistency — the +/// invariant we're testing. +fn actual_index(conn: &Connection) -> BTreeSet<(i64, String)> { + // Iterate every segment row (live + soft-deleted) and probe whether + // its rowid is reachable via FTS by matching a unique token from its + // original text. If reachable, record (rowid, text). + let mut stmt = conn + .prepare( + "SELECT s.rowid, s.text + FROM transcript_segment s", + ) + .expect("prepare segments"); + let segs: Vec<(i64, String)> = stmt + .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))) + .expect("query segments") + .filter_map(Result::ok) + .collect(); + drop(stmt); + + let mut out = BTreeSet::new(); + for (rowid, text) in segs { + // Pick the first whitespace-tokenised word; FTS5 unicode61 tokens + // are lowercase + alphanumeric subsets of words. If `text` is + // empty (shouldn't happen given the seeded universe), skip. + let probe_token = text.split_whitespace().next().unwrap_or(""); + if probe_token.is_empty() { + continue; + } + // Quote token to avoid FTS5 query-syntax collisions on punctuation. + let q = format!("\"{probe_token}\""); + let hit: Option = conn + .query_row( + "SELECT rowid FROM transcript_search + WHERE rowid = ?1 AND text MATCH ?2", + params![rowid, &q], + |r| r.get(0), + ) + .ok(); + if hit.is_some() { + out.insert((rowid, text)); + } + } + out +} + +// --------------------------------------------------------------------------- +// Op universe +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum TranscriptOp { + InsertSegment(usize, usize), // (transcript_idx, text_idx) + SoftDeleteSegment(usize), // segment seq idx + RestoreSegment(usize), // segment seq idx + SoftDeleteTranscript(usize), // transcript_idx (cascade) + RestoreTranscript(usize), // transcript_idx (cascade) + EditSegmentText(usize, usize), // (segment seq idx, new text idx) + /// Re-inserts a transcript header is not exercised — we seed all + /// transcripts at startup. The op universe focuses on per-segment + /// churn + transcript-level cascade arms, which is where the FTS5 + /// trigger correctness matters. + Touch, +} + +// --------------------------------------------------------------------------- +// Proptest +// --------------------------------------------------------------------------- + +proptest::proptest! { + // WHY cases=32: matches the soft-delete-churn sibling + // `fts_matches_ground_truth_under_soft_delete_churn` cap (#124). + // Per-case work is similar shape (writer thread + 2 raw connections + // + up to 25 ops + per-op ground-truth comparison) so we use the + // same tighter cap rather than the 64-cap of the lighter + // `fts_consistent_under_tag_churn` proptest. + #![proptest_config(proptest::test_runner::Config { + cases: 32, + ..proptest::test_runner::Config::default() + })] + + /// **Invariant:** after EVERY op, `transcript_search` rows + /// (incrementally maintained by FTS5 codegen triggers) MUST equal + /// the ground-truth set computed directly from joined live state. + #[test] + fn transcript_search_matches_ground_truth_under_soft_delete_churn( + ops in proptest::collection::vec( + proptest::prop_oneof![ + proptest::strategy::Strategy::prop_map( + (0..TRANSCRIPT_COUNT, 0..SEGMENT_TEXTS.len()), + |(t, x)| TranscriptOp::InsertSegment(t, x), + ), + proptest::strategy::Strategy::prop_map( + 0..16usize, + TranscriptOp::SoftDeleteSegment, + ), + proptest::strategy::Strategy::prop_map( + 0..16usize, + TranscriptOp::RestoreSegment, + ), + proptest::strategy::Strategy::prop_map( + 0..TRANSCRIPT_COUNT, + TranscriptOp::SoftDeleteTranscript, + ), + proptest::strategy::Strategy::prop_map( + 0..TRANSCRIPT_COUNT, + TranscriptOp::RestoreTranscript, + ), + proptest::strategy::Strategy::prop_map( + (0..16usize, 0..SEGMENT_TEXTS.len()), + |(s, x)| TranscriptOp::EditSegmentText(s, x), + ), + proptest::strategy::Just(TranscriptOp::Touch), + ], + 0..25, + ), + ) { + let (_td, db, writer) = scratch_writer(); + let conn = seed_conn(&db); + let transcript_ids = seed_transcripts(&conn); + + // Stable IDs for segments seeded by InsertSegment ops, in the order + // they were inserted. Ops referencing a not-yet-existing segment + // index become no-ops (cleaner than aborting a case mid-sequence). + let mut segment_ids: Vec = Vec::new(); + let mut next_seg_seq: u64 = 0; + + for op in &ops { + match op { + TranscriptOp::InsertSegment(t, x) => { + let seg_id = format!("s{next_seg_seq:063}"); + next_seg_seq += 1; + insert_segment( + &conn, + &seg_id, + &transcript_ids[*t], + SEGMENT_TEXTS[*x], + ); + segment_ids.push(seg_id); + } + TranscriptOp::SoftDeleteSegment(s) => { + if let Some(id) = segment_ids.get(*s) { + soft_delete_segment(&conn, id); + } + } + TranscriptOp::RestoreSegment(s) => { + if let Some(id) = segment_ids.get(*s) { + restore_segment(&conn, id); + } + } + TranscriptOp::SoftDeleteTranscript(t) => { + soft_delete_transcript(&conn, &transcript_ids[*t]); + } + TranscriptOp::RestoreTranscript(t) => { + restore_transcript(&conn, &transcript_ids[*t]); + } + TranscriptOp::EditSegmentText(s, x) => { + if let Some(id) = segment_ids.get(*s) { + edit_segment_text(&conn, id, SEGMENT_TEXTS[*x]); + } + } + TranscriptOp::Touch => { /* explicit no-op */ } + } + + // Re-open RO conn to ensure WAL visibility of the writes + // performed via the seed_conn — the FTS5 triggers fire + // synchronously inside the same connection so seed_conn + // already sees them, but the asserter uses an independent + // connection to avoid any same-conn statement-cache effects. + let ro = ro_conn(&db); + let actual = actual_index(&ro); + let expected = ground_truth(&ro); + drop(ro); + + proptest::prop_assert_eq!( + actual.clone(), + expected.clone(), + "transcript_search drifted from ground truth after op {:?} in sequence {:?} \ + — actual={:?} expected={:?}", + op, ops, actual, expected + ); + } + + drop(conn); + writer.join(); + } +} diff --git a/crates/db/tests/writer_transcript.rs b/crates/db/tests/writer_transcript.rs new file mode 100644 index 0000000..2f2d968 --- /dev/null +++ b/crates/db/tests/writer_transcript.rs @@ -0,0 +1,209 @@ +//! Writer-cmd integration test for `WriteCmd::Transcript(TranscriptWriteCmd::Insert)`. +//! +//! Verifies: +//! - **Atomicity** — transcript + segments commit together; FTS5 +//! maintenance triggers fire and `transcript_search` is queryable. +//! - **Cancel rollback** — pre-cancelled token causes the writer to +//! roll back without writing anything; reply is +//! `Err(CoreError::Transcription(Cancelled))`. + +#![allow(clippy::unwrap_used)] // WHY: integration test; unwrap panics signal bugs. + +use std::sync::Arc; +use std::time::Duration; + +use rusqlite::{Connection, OpenFlags}; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; + +use perima_core::transcription::TranscriptionError; +use perima_core::{CoreError, EventBus}; +use perima_db::cmd::{ReplyTx, TranscriptWriteCmd, WriteCmd}; +use perima_db::test_utils::NoopBus; +use perima_db::transcript_repo::{TranscriptId, TranscriptRow, TranscriptSegmentRow}; +use perima_db::writer::SqliteWriter; + +// --------------------------------------------------------------------------- +// Local helpers (kept here rather than in tests/common/mod.rs because no +// other transcript test exists yet — the proptest below seeds via raw SQL, +// not via the writer, so it doesn't need this scaffolding). +// --------------------------------------------------------------------------- + +/// Build a tempfile-on-disk DB + writer actor. Mirrors `common::test_db()` +/// but without a `SearchRepository` (transcripts have their own search +/// surface; this test reads via a raw RO connection). +fn scratch_writer() -> (TempDir, std::path::PathBuf, perima_db::SqliteWriterHandle) { + let td = tempfile::tempdir().expect("tempdir"); + let db_path = td.path().join("transcripts.db"); + let bus: Arc = Arc::new(NoopBus); + let writer = SqliteWriter::start(&db_path, bus).expect("writer start"); + (td, db_path, writer) +} + +/// Open a fresh read-only connection on the scratch DB. Avoids a second +/// writable handle (clippy.toml bans `Connection::open` outside a curated +/// allow-list per GH #131). +fn ro_conn(db_path: &std::path::Path) -> Connection { + Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("ro conn open") +} + +fn make_segment(start_ms: u32, end_ms: u32, text: &str) -> TranscriptSegmentRow { + TranscriptSegmentRow { + id: TranscriptId::new(), + // transcript_id is overridden by the writer with the parent id; + // the empty string here documents that intent. + transcript_id: TranscriptId(String::new()), + start_ms, + end_ms, + text: text.to_owned(), + confidence: Some(0.9), + } +} + +/// Happy path: header + 3 segments commit together; the codegen FTS5 +/// trigger fires on each segment INSERT (verified by `MATCH 'hello'`). +/// Atomicity-under-fault is verified by the cancel-rollback test below +/// and by `transcript_proptests::transcript_search_matches_ground_truth_under_soft_delete_churn`. +#[test] +fn insert_persists_transcript_and_segments_atomically() { + let (_td, db_path, writer) = scratch_writer(); + + let transcript_id = TranscriptId::new(); + let transcript = TranscriptRow { + id: transcript_id.clone(), + // FK to files.file_uuid is structurally NOT NULL but unenforced + // (no FK constraint in the v1 schema). A fresh UUIDv7 is fine. + file_uuid: uuid::Uuid::now_v7().to_string(), + backend: "groq:whisper-large-v3-turbo".to_owned(), + language: Some("en".to_owned()), + duration_ms: 5_000, + }; + let segments = vec![ + make_segment(0, 1500, "hello world"), + make_segment(1500, 3000, "this is a test"), + make_segment(3000, 5000, "of transcription"), + ]; + + let (reply_tx, reply_rx): (ReplyTx, _) = flume::bounded(1); + writer + .sender() + .send(WriteCmd::Transcript(TranscriptWriteCmd::Insert { + transcript, + segments, + device: "test-device-001".to_owned(), + cancel: None, + request_uuid: uuid::Uuid::now_v7().simple().to_string(), + reply: reply_tx, + })) + .unwrap(); + + let id = reply_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + assert_eq!(id, transcript_id); + + // Verify rows exist + FTS index was populated by the codegen-installed + // `transcript_search_after_segment_insert` trigger. + let conn = ro_conn(&db_path); + let count: i64 = conn + .query_row( + "SELECT count(*) FROM transcript WHERE id = ?1", + rusqlite::params![&transcript_id.0], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "transcript header row missing"); + + let seg_count: i64 = conn + .query_row( + "SELECT count(*) FROM transcript_segment WHERE transcript_id = ?1", + rusqlite::params![&transcript_id.0], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(seg_count, 3, "expected 3 segment rows"); + + // Verify the codegen FTS5 trigger fired: query `hello` should find one + // segment containing "hello world". + let fts_count: i64 = conn + .query_row( + "SELECT count(*) FROM transcript_search WHERE text MATCH 'hello'", + rusqlite::params![], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(fts_count, 1, "FTS5 trigger did not fire"); + + drop(conn); + writer.join(); +} + +#[test] +fn insert_rolls_back_when_cancel_fires_before_first_insert() { + let (_td, db_path, writer) = scratch_writer(); + + let transcript = TranscriptRow { + id: TranscriptId::new(), + file_uuid: uuid::Uuid::now_v7().to_string(), + backend: "groq:whisper-large-v3-turbo".to_owned(), + language: None, + duration_ms: 1_000, + }; + let segments = vec![make_segment(0, 1000, "should not land")]; + + // Pre-fire cancel so the post-BEGIN check trips immediately. + let cancel = CancellationToken::new(); + cancel.cancel(); + + let (reply_tx, reply_rx): (ReplyTx, _) = flume::bounded(1); + writer + .sender() + .send(WriteCmd::Transcript(TranscriptWriteCmd::Insert { + transcript, + segments, + device: "test-device-002".to_owned(), + cancel: Some(cancel), + request_uuid: uuid::Uuid::now_v7().simple().to_string(), + reply: reply_tx, + })) + .unwrap(); + + let result = reply_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!( + matches!( + result, + Err(CoreError::Transcription(TranscriptionError::Cancelled)) + ), + "expected Err(Transcription(Cancelled)), got {result:?}", + ); + + // Verify nothing landed. + let conn = ro_conn(&db_path); + let count: i64 = conn + .query_row( + "SELECT count(*) FROM transcript", + rusqlite::params![], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0, "transcript row leaked despite cancel rollback"); + let seg_count: i64 = conn + .query_row( + "SELECT count(*) FROM transcript_segment", + rusqlite::params![], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + seg_count, 0, + "transcript_segment row leaked despite cancel rollback" + ); + + drop(conn); + writer.join(); +} diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index be2108f..7b6e662 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -31,12 +31,32 @@ perima-db = { path = "../db" } perima-fs = { path = "../fs" } perima-hash = { path = "../hash" } perima-media.workspace = true +# WHY perima-transcribe + tempfile in desktop: the placeholder +# MissingFfmpegPipeline (in lib.rs) implements `AudioPipeline` and returns +# `NamedTempFile`. T7 will swap in the Tauri-resolved sidecar invoker. +perima-transcribe = { path = "../transcribe" } +tempfile.workspace = true miette.workspace = true tauri.workspace = true tauri-specta.workspace = true specta.workspace = true specta-typescript.workspace = true tauri-plugin-dialog.workspace = true +# WHY tauri-plugin-shell (T7): exposes `app.shell()` + `app.path().resolve(..)` +# infrastructure used to locate the bundled ffmpeg sidecar binary. The plugin +# itself is initialised in `lib.rs::run` BEFORE `.setup(...)` so the plugin +# state is available inside the setup hook. +tauri-plugin-shell.workspace = true +# WHY which (T7): when the bundled ffmpeg sidecar (T12) hasn't been wired +# yet, we fall back to the system `ffmpeg` on PATH so transcription stays +# usable in development builds. +which.workspace = true +# WHY keyring (T7): the new provider-key Tauri commands (`set_provider_key`, +# `delete_provider_key`, `has_provider_key`, `list_providers`) read/write OS +# keyring entries under `KEYRING_SERVICE`. Same crate the CLI's `auth` +# subcommand and the `AppContainer` use; keeping them aligned guarantees a +# key set by one shell is visible to the other. +keyring.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/desktop/binaries/.gitkeep b/crates/desktop/binaries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/desktop/build.rs b/crates/desktop/build.rs index e9a85d4..75b9213 100644 --- a/crates/desktop/build.rs +++ b/crates/desktop/build.rs @@ -8,5 +8,15 @@ // below makes the intent explicit. #![allow(missing_docs)] fn main() { + // WHY: forward cargo's TARGET env var (e.g. "x86_64-unknown-linux-gnu") + // into the compiled binary so the runtime can resolve the bundled + // ffmpeg sidecar at the path Tauri's `externalBin` rewriter actually + // uses (`binaries/ffmpeg-{target-triple}`). std::env::consts::ARCH + // gives only the architecture ("x86_64"), which would never match the + // sidecar filename and silently break the T12 bundling story. + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").expect("cargo always sets TARGET for build scripts") + ); tauri_build::build(); } diff --git a/crates/desktop/capabilities/default.json b/crates/desktop/capabilities/default.json index 62a8bd1..2b28715 100644 --- a/crates/desktop/capabilities/default.json +++ b/crates/desktop/capabilities/default.json @@ -3,6 +3,16 @@ "windows": ["main"], "permissions": [ "core:default", - "dialog:default" + "dialog:default", + { + "identifier": "shell:allow-execute", + "allow": [ + { + "name": "binaries/ffmpeg", + "sidecar": true, + "args": true + } + ] + } ] } diff --git a/crates/desktop/gen/schemas/acl-manifests.json b/crates/desktop/gen/schemas/acl-manifests.json index 116d3af..9a894c2 100644 --- a/crates/desktop/gen/schemas/acl-manifests.json +++ b/crates/desktop/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/crates/desktop/gen/schemas/capabilities.json b/crates/desktop/gen/schemas/capabilities.json index 8fd2bad..c90651d 100644 --- a/crates/desktop/gen/schemas/capabilities.json +++ b/crates/desktop/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"","local":true,"windows":["main"],"permissions":["core:default","dialog:default",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"binaries/ffmpeg","sidecar":true}]}]}} \ No newline at end of file diff --git a/crates/desktop/gen/schemas/desktop-schema.json b/crates/desktop/gen/schemas/desktop-schema.json index a1f4665..634faec 100644 --- a/crates/desktop/gen/schemas/desktop-schema.json +++ b/crates/desktop/gen/schemas/desktop-schema.json @@ -134,6 +134,216 @@ "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, { "properties": { "identifier": { @@ -2209,6 +2419,72 @@ "type": "string", "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." } ] }, @@ -2305,6 +2581,50 @@ ] } ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] } } } \ No newline at end of file diff --git a/crates/desktop/gen/schemas/linux-schema.json b/crates/desktop/gen/schemas/linux-schema.json index a1f4665..634faec 100644 --- a/crates/desktop/gen/schemas/linux-schema.json +++ b/crates/desktop/gen/schemas/linux-schema.json @@ -134,6 +134,216 @@ "description": "Reference a permission or permission set by identifier and extends its scope.", "type": "object", "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "deny": { + "items": { + "title": "ShellScopeEntry", + "description": "Shell scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "cmd", + "name" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "cmd": { + "description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name", + "sidecar" + ], + "properties": { + "args": { + "description": "The allowed arguments for the command execution.", + "allOf": [ + { + "$ref": "#/definitions/ShellScopeEntryAllowedArgs" + } + ] + }, + "name": { + "description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.", + "type": "string" + }, + "sidecar": { + "description": "If this command is a sidecar command.", + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, { "properties": { "identifier": { @@ -2209,6 +2419,72 @@ "type": "string", "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." + }, + { + "description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`", + "type": "string", + "const": "shell:default", + "markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`" + }, + { + "description": "Enables the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-execute", + "markdownDescription": "Enables the execute command without any pre-configured scope." + }, + { + "description": "Enables the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-kill", + "markdownDescription": "Enables the kill command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-spawn", + "markdownDescription": "Enables the spawn command without any pre-configured scope." + }, + { + "description": "Enables the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:allow-stdin-write", + "markdownDescription": "Enables the stdin_write command without any pre-configured scope." + }, + { + "description": "Denies the execute command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-execute", + "markdownDescription": "Denies the execute command without any pre-configured scope." + }, + { + "description": "Denies the kill command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-kill", + "markdownDescription": "Denies the kill command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the spawn command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-spawn", + "markdownDescription": "Denies the spawn command without any pre-configured scope." + }, + { + "description": "Denies the stdin_write command without any pre-configured scope.", + "type": "string", + "const": "shell:deny-stdin-write", + "markdownDescription": "Denies the stdin_write command without any pre-configured scope." } ] }, @@ -2305,6 +2581,50 @@ ] } ] + }, + "ShellScopeEntryAllowedArg": { + "description": "A command argument allowed to be executed by the webview API.", + "anyOf": [ + { + "description": "A non-configurable argument that is passed to the command in the order it was specified.", + "type": "string" + }, + { + "description": "A variable that is set while calling the command from the webview API.", + "type": "object", + "required": [ + "validator" + ], + "properties": { + "raw": { + "description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.", + "default": false, + "type": "boolean" + }, + "validator": { + "description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "ShellScopeEntryAllowedArgs": { + "description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.", + "anyOf": [ + { + "description": "Use a simple boolean to allow all or disable all arguments to this command configuration.", + "type": "boolean" + }, + { + "description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.", + "type": "array", + "items": { + "$ref": "#/definitions/ShellScopeEntryAllowedArg" + } + } + ] } } } \ No newline at end of file diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index e62c598..93c4ee2 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -26,8 +26,9 @@ use std::sync::Arc; use std::time::Duration; use perima_app::{ - EventHandler, FullScan, MetadataCommand, MetadataOutput, ScanCommand, ScanReport, - SearchCommand, SearchOutput, TagCommand, TagFilter, TagOutput, VolumeCommand, VolumeOutput, + EventHandler, FullScan, KEYRING_SERVICE, MetadataCommand, MetadataOutput, ScanCommand, + ScanReport, SearchCommand, SearchOutput, TagCommand, TagFilter, TagOutput, TranscribeCommand, + TranscribeOutput, VolumeCommand, VolumeOutput, config::transcription::TranscriptionConfig, }; use perima_core::{ AppEvent, BatchHandle, BatchId, BlakeHash, CollisionGroup, CoreError, DeviceId, EventBus, @@ -43,7 +44,10 @@ use perima_media::{ use rayon::prelude::*; use tokio_util::sync::CancellationToken; -use crate::payloads::{FileWithMetadataPayload, FileWithTagsPayload}; +use crate::payloads::{ + FileWithMetadataPayload, FileWithTagsPayload, ListProvidersPayload, ProviderListEntry, + TranscribeStartedPayload, +}; use crate::state::{AppState, WatcherState}; /// Parses a string into a `VolumeId`, wrapping a `Uuid` parse failure @@ -536,7 +540,7 @@ pub async fn list_files_with_metadata( // `list_files` for maintainability. let filtered: Vec = rows .into_iter() - .filter(|(loc, _, _)| volume_id.is_none_or(|v| loc.volume_id == v)) + .filter(|(loc, _, _, _)| volume_id.is_none_or(|v| loc.volume_id == v)) .map(FileWithMetadataPayload::from) .collect(); Ok(filtered) @@ -1092,7 +1096,12 @@ pub async fn list_files_with_tags( Ok(files .into_iter() .map(|fwt| FileWithTagsPayload { - file: FileWithMetadataPayload::from((fwt.location, fwt.metadata, fwt.quick_hash)), + file: FileWithMetadataPayload::from(( + fwt.location, + fwt.metadata, + fwt.quick_hash, + fwt.mount_path, + )), tags: fwt.tags, }) .collect()) @@ -1126,15 +1135,15 @@ where // surface as the empty Vec below. To attach tags to pending files use the // `attach_tag_by_uuid` IPC command. let hashes: Vec = - rows.iter().filter_map(|(loc, _, _)| loc.hash).collect(); + rows.iter().filter_map(|(loc, _, _, _)| loc.hash).collect(); let tag_map = tag_repo.tags_for_hashes(&hashes)?; Ok(rows .into_iter() - .map(|(loc, meta, quick_hash)| { + .map(|(loc, meta, quick_hash, mount_path)| { let tags = loc .hash .map_or_else(Vec::new, |h| tag_map.get(&h).cloned().unwrap_or_default()); - let file = FileWithMetadataPayload::from((loc, meta, quick_hash)); + let file = FileWithMetadataPayload::from((loc, meta, quick_hash, mount_path)); FileWithTagsPayload { file, tags } }) .collect()) @@ -1642,3 +1651,301 @@ pub async fn backup_database( .execute(perima_app::BackupCommand { target, force }) .await } + +// --------------------------------------------------------------------------- +// Transcription commands (T7) +// +// All eight handlers return `Result` per the Batch D IPC contract +// (CLAUDE.md). Wire-types crossing IPC derive `specta::Type`; see +// `crates/desktop/src/payloads.rs` for the wire-type WHY-block. +// +// Keyring posture mirrors the CLI `auth` subcommand +// (`crates/cli/src/cmd/auth.rs`): same `KEYRING_SERVICE` constant, same +// idempotent-delete semantics, same `NoEntry`-as-success policy on lookups. +// --------------------------------------------------------------------------- + +/// Construct a [`keyring::Entry`] for the given provider, mapping construction +/// failures to [`CoreError::Internal`]. Mirrors `cli/src/cmd/auth.rs`. +fn provider_keyring_entry(provider: &str) -> Result { + keyring::Entry::new(KEYRING_SERVICE, provider) + .map_err(|e| CoreError::Internal(format!("keyring entry: {e}"))) +} + +/// Start a new transcription job. +/// +/// Returns immediately with the freshly-minted `request_uuid` and 1-based +/// queue position; the worker picks up the job asynchronously and publishes +/// [`perima_core::AppEvent::TranscriptionStarted`] on the shared bus when +/// it begins (the Tauri channel `"app-event"` carries every `AppEvent`). +/// +/// `source` is a [`String`] (not `PathBuf`) because Tauri serializes +/// paths as strings; the handler converts to [`PathBuf`] before invoking +/// the use-case. +/// +/// # Errors +/// Returns [`CoreError::Transcription`] wrapping +/// [`perima_core::transcription::TranscriptionError::QueueFull`] when the +/// bounded queue is at capacity; or other [`CoreError`] variants from the +/// transcription use-case. +// WHY allow: Tauri owns `State` + `String`/`Option` params. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +pub async fn transcribe( + file_uuid: String, + file_name: String, + source: String, + language_hint: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let out = state + .container + .transcription + .execute(TranscribeCommand::Start { + file_uuid, + file_name, + source: PathBuf::from(source), + language_hint, + }) + .await?; + // WHY explicit match (not `let TranscribeOutput::Started { .. } = out else + // ...`): the `Cancelled` variant must never appear in response to a + // `Start` command. Surfacing it as `CoreError::Internal` keeps the IPC + // contract typed without widening `TranscribeStartedPayload` to also + // model the cancelled case. + match out { + TranscribeOutput::Started { + request_uuid, + queue_position, + } => Ok(TranscribeStartedPayload { + request_uuid, + queue_position, + }), + TranscribeOutput::Cancelled { .. } => Err(CoreError::Internal( + "TranscribeCommand::Start returned Cancelled output".into(), + )), + } +} + +/// Cancel an in-flight or queued transcription job by `request_uuid`. +/// +/// Idempotent — cancelling an unknown id is a no-op (the use-case removes +/// the entry from its cancel map and returns Cancelled either way). +/// +/// # Errors +/// Returns a [`CoreError`] only if the use-case itself fails; the typical +/// path returns `Ok(())`. +// WHY allow: Tauri owns `State` and `String` params. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +pub async fn cancel_transcription( + request_uuid: String, + state: tauri::State<'_, AppState>, +) -> Result<(), CoreError> { + let _ = state + .container + .transcription + .execute(TranscribeCommand::Cancel { request_uuid }) + .await?; + Ok(()) +} + +/// Store an API key under the given provider name in the OS keyring under +/// service [`perima_app::KEYRING_SERVICE`] (`"perima.transcription"`). +/// +/// Overwrites any existing key for the same provider (matches the CLI +/// `auth set` semantics). +/// +/// # Errors +/// Returns [`CoreError::Internal`] on keyring failures. +// WHY allow: Tauri owns `State` and `String` params. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +// WHY allow unused_async: keyring operations are sync, but the Tauri command +// surface is async-by-convention to match every other handler in this module. +pub async fn set_provider_key( + provider: String, + api_key: String, + _state: tauri::State<'_, AppState>, +) -> Result<(), CoreError> { + let entry = provider_keyring_entry(&provider)?; + entry + .set_password(&api_key) + .map_err(|e| CoreError::Internal(format!("keyring set: {e}"))) +} + +/// Remove the keyring entry for `provider`. Idempotent — returns `Ok(())` +/// when no entry exists (matches CLI `auth delete`). +/// +/// # Errors +/// Returns [`CoreError::Internal`] on keyring failures other than +/// [`keyring::Error::NoEntry`]. +// WHY allow: Tauri owns `State` and `String` params. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +pub async fn delete_provider_key( + provider: String, + _state: tauri::State<'_, AppState>, +) -> Result<(), CoreError> { + let entry = provider_keyring_entry(&provider)?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(CoreError::Internal(format!("keyring delete: {e}"))), + } +} + +/// Return `true` if a keyring entry exists for `provider`, `false` otherwise. +/// +/// # Errors +/// Returns [`CoreError::Internal`] on keyring failures other than +/// [`keyring::Error::NoEntry`] (which maps to `Ok(false)`). +// WHY allow: Tauri owns `State` and `String` params. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +pub async fn has_provider_key( + provider: String, + _state: tauri::State<'_, AppState>, +) -> Result { + let entry = provider_keyring_entry(&provider)?; + match entry.get_password() { + Ok(_) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(CoreError::Internal(format!("keyring get: {e}"))), + } +} + +/// List every configured transcription provider with its preset, optional +/// model override, and a `has_key` flag computed via a per-provider keyring +/// lookup. +/// +/// `active` echoes [`TranscriptionConfig::active_provider`]. +/// +/// # Errors +/// Returns [`CoreError::Internal`] when [`TranscriptionConfig::load`] fails +/// (malformed `config.toml`). +/// +/// # Panics +/// Panics only on logic-bug paths inside the `HashMap::get(name)` lookup +/// — `name` was just enumerated from `cfg.providers.keys()`, so the entry +/// must exist. Treated as an unrecoverable bug if it ever fires. +// WHY allow: Tauri owns `State`. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +pub async fn list_providers( + state: tauri::State<'_, AppState>, +) -> Result { + // WHY load via state.data_dir.parent(): config_dir is not on AppState; the + // Tauri shell stores it implicitly via `resolve_with_app_data_dir` — + // `data_dir` lives at `/perima/`, so config_dir is its + // parent. This avoids widening AppState mid-task. + let config_dir = state + .data_dir + .parent() + .ok_or_else(|| CoreError::Internal("data_dir has no parent (config_dir)".into()))?; + let cfg = TranscriptionConfig::load(config_dir)?; + + // Sort provider names for deterministic output (matches CLI `auth list`). + let mut names: Vec<&String> = cfg.providers.keys().collect(); + names.sort(); + + let providers = names + .into_iter() + .map(|name| { + let entry = cfg.providers.get(name).expect("name was just enumerated"); + // WHY treat construction failure as "no key": surfacing the + // raw keyring error here would block the whole settings + // panel; the user's recovery path is to set the key + // (which goes through `set_provider_key` and surfaces its + // own error if construction fails again). Log the + // construction failure so a system-keyring outage is + // diagnosable without piecing together other commands' errors. + let has_key = match provider_keyring_entry(name) { + Ok(entry) => entry.get_password().is_ok(), + Err(e) => { + tracing::warn!(provider = %name, error = %e, "keyring entry construction failed in list_providers; reporting has_key=false"); + false + } + }; + ProviderListEntry { + name: name.clone(), + preset: entry.preset.clone(), + model: entry.model.clone(), + has_key, + } + }) + .collect(); + + Ok(ListProvidersPayload { + active: cfg.active_provider, + providers, + }) +} + +/// Persist `config` to `/config.toml` via [`TranscriptionConfig::save`]. +/// +/// **Hot reload not implemented in T7.** Rebuilding the active +/// [`perima_app::TranscriptionUseCase`] in-place would require swapping the +/// `Arc` field on `AppContainer` (which is currently +/// immutable after construction) and gracefully draining the existing worker +/// task. That refactor is deferred — for now we save the file and emit a +/// `tracing::warn!` instructing the user to relaunch the app for the new +/// provider config to take effect. The frontend should surface a banner with +/// the same message. +/// +/// # Errors +/// Returns [`CoreError::Internal`] on filesystem or TOML serialization +/// failures. +// WHY allow: Tauri owns `State` and the value-param `config`. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +pub async fn update_transcription_config( + config: TranscriptionConfig, + state: tauri::State<'_, AppState>, +) -> Result<(), CoreError> { + let config_dir = state + .data_dir + .parent() + .ok_or_else(|| CoreError::Internal("data_dir has no parent (config_dir)".into()))?; + // WHY sync I/O on a Tauri worker thread (no spawn_blocking): TOML + // serialize + write of a tiny config file (<2 KiB) completes in + // sub-ms on every realistic disk; the runtime cost of spawn_blocking + // (thread handoff + cache miss) would dwarf the I/O it dispatches. + config.save(config_dir)?; + tracing::warn!( + "transcription config updated; restart perima for the new provider \ + configuration to take effect" + ); + Ok(()) +} + +/// Read the current [`TranscriptionConfig`] from `/config.toml`. +/// Returns the default (no providers, no active) when the file is missing. +/// +/// # Errors +/// Returns [`CoreError::Internal`] when the config file exists but is +/// malformed TOML. +// WHY allow: Tauri owns `State`. +#[allow(clippy::needless_pass_by_value)] +#[tauri::command] +#[specta::specta] +#[allow(clippy::unused_async)] +pub async fn get_transcription_config( + state: tauri::State<'_, AppState>, +) -> Result { + let config_dir = state + .data_dir + .parent() + .ok_or_else(|| CoreError::Internal("data_dir has no parent (config_dir)".into()))?; + TranscriptionConfig::load(config_dir) +} diff --git a/crates/desktop/src/lib.rs b/crates/desktop/src/lib.rs index 98b42c0..19b61cc 100644 --- a/crates/desktop/src/lib.rs +++ b/crates/desktop/src/lib.rs @@ -40,6 +40,7 @@ use perima_fs::WalkdirScanner; use perima_hash::Blake3Service; use perima_media::ThumbnailGenerator; use tauri::Manager; +use tauri::path::BaseDirectory; use tauri_specta::{Builder, collect_commands}; use crate::commands::DbEventHandler; @@ -60,6 +61,23 @@ impl EventBus for LocalNoopBus { } } +/// Stub `AudioPipeline` used until T7 wires the Tauri-resolved bundled +/// ffmpeg sidecar. Surfaces a typed `BinaryNotFound` only if a transcription +/// job actually runs; non-transcription handlers proceed normally. +struct MissingFfmpegPipeline; + +impl perima_transcribe::audio::AudioPipeline for MissingFfmpegPipeline { + fn remux_for_upload( + &self, + _input: &Path, + _cancel: &tokio_util::sync::CancellationToken, + ) -> Result { + Err(perima_transcribe::audio::AudioError::BinaryNotFound( + "bundled ffmpeg sidecar not yet wired (T7); transcription will be available in a later release".to_owned(), + )) + } +} + /// Tuple returned by [`build_container`] — collapsed via type alias to /// satisfy `clippy::type_complexity`. type BuildContainerOutput = ( @@ -90,6 +108,12 @@ pub type RunError = Box; /// export fails when the `specta-export` feature is enabled, or if the Tauri /// event loop exits with an error. No panic paths remain; all previously /// `.expect()`-ed sites now propagate via `?`. +// WHY allow too_many_lines: `run()` is the single Tauri startup-sequence +// function; splitting it would require threading half a dozen locals +// (specta_builder, log_guard, app_handle) through helper signatures with +// no real readability gain. The 100-line cap was crossed by T7's audio +// pipeline + container-bus refactor; tracked as cosmetic, not structural. +#[allow(clippy::too_many_lines)] pub fn run() -> Result<(), RunError> { // WHY: tauri-specta Builder collects #[specta::specta]-annotated commands // and generates TypeScript bindings when the `specta-export` feature is @@ -118,6 +142,15 @@ pub fn run() -> Result<(), RunError> { commands::list_quick_hash_collisions, commands::mark_verified_distinct, commands::backup_database, + // T7: transcription commands. + commands::transcribe, + commands::cancel_transcription, + commands::set_provider_key, + commands::delete_provider_key, + commands::has_provider_key, + commands::list_providers, + commands::update_transcription_config, + commands::get_transcription_config, ]); // Export TypeScript bindings when the `specta-export` feature is @@ -140,6 +173,14 @@ pub fn run() -> Result<(), RunError> { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + // WHY tauri-plugin-shell init BEFORE .setup(...) (T7): the plugin + // wires the `app.shell()` extension trait + `app.path().resolve(...)` + // helpers used by the ffmpeg sidecar path discovery inside `.setup`. + // Initialising it after `.setup` would leave `app.shell()` unresolved + // at the moment we need it. Future T12 (sidecar bundling) consumes + // the same plugin to actually launch the sidecar binary; T7 only + // resolves the path. + .plugin(tauri_plugin_shell::init()) .manage(state::WatcherState::new()) .invoke_handler(specta_builder.invoke_handler()) .setup(move |app| { @@ -184,6 +225,19 @@ pub fn run() -> Result<(), RunError> { // (DbEventHandler) and for build_container (primary writer). let db_path = cfg.data_dir.join("perima.db"); + // WHY resolve ffmpeg sidecar path here (T7): the audio pipeline + // adapter constructor requires a `PathBuf`. We try the bundled + // sidecar first (`app.path().resolve("ffmpeg-{target-triple}", + // BaseDirectory::Resource)`); if T12 hasn't bundled the sidecar + // yet (Resource path missing/inaccessible) we fall back to + // PATH-discovered system ffmpeg via `which::which`. Either way + // the resolved `PathBuf` is wrapped in + // `DesktopFfmpegInvoker` + `FfmpegAudioPipeline`. If both fail, + // we install the deferred-error `MissingFfmpegPipeline` so + // non-transcription commands continue working and only + // `transcribe` surfaces `BinaryNotFound` to the UI. + let audio_pipeline = resolve_audio_pipeline(app.handle()); + // WHY build the AppContainer here, not per-command: a single // container is reused across every Tauri command dispatch via // `manage(state)`. CLI builds one per dispatch (short-lived @@ -226,8 +280,13 @@ pub fn run() -> Result<(), RunError> { }), ]; - let (container, writer_handle, tag_repo, metadata_repo, search_repo) = - build_container(&db_path, handlers)?; + let (container, writer_handle, tag_repo, metadata_repo, search_repo) = build_container( + &db_path, + &cfg.config_dir, + cfg.device_id, + handlers, + audio_pipeline, + )?; // WHY `manage(writer_handle)`: the writer thread stays // alive as long as at least one `flume::Sender` @@ -346,16 +405,27 @@ fn spawn_backfill_desktop( /// retains concrete-typed `Arc`s for the `_inner` test-helper seam — those /// helpers construct their own repos per-call and need the concrete type for /// methods not exposed by the trait object. +/// +/// WHY [`AppContainer::new_with_bus`] (T7): the writer actor must publish to +/// the SAME [`perima_app::Bus`] that the use-cases publish to, otherwise +/// `AppEvent::TranscriptionCompleted` (writer-side, post-COMMIT) never reaches +/// the [`crate::events::TauriEventHandler`] and the frontend's settle-state +/// callback for a transcription job never fires. Mirrors the +/// `crates/cli/src/main.rs::build_container` posture. fn build_container( db_path: &Path, + config_dir: &Path, + device_id: perima_core::DeviceId, handlers: Vec>, + audio_pipeline: Arc, ) -> Result { - // WHY a `NoopBus` to the writer: the writer's after-COMMIT emission - // path is scaffolded but `AppEvent` emission is handled by - // `AppContainer`'s `Bus` (Batch E). Batch E's `async-broadcast` - // will re-plumb this once the single-construction-site invariant is - // relaxed. Spec §§3.3 + 4.8 (A4.8 first bullet). - let writer_bus: Arc = Arc::new(LocalNoopBus); + // WHY construct the Bus here (not inside AppContainer::new): the writer + // actor needs the SAME bus the use-cases publish to. T6 introduced + // `AppContainer::new_with_bus` for this — the bus stays the single + // construction site per process; the construction site simply moves + // from inside `AppContainer::new` into the shell. + let bus = perima_app::Bus::new(); + let writer_bus: Arc = bus.clone(); let writer = SqliteWriter::start(db_path, writer_bus)?; let reads = ReadPool::open(db_path)?; @@ -406,6 +476,11 @@ fn build_container( .unwrap_or_else(|| Path::new(".")) .to_path_buf(); + // Transcript repo for the transcription use-case. + let transcript_repo: Arc = Arc::new( + perima_db::SqliteTranscriptRepository::new(writer.sender(), ReadPool::open(db_path)?), + ); + let deps = AppDeps { admin, data_dir, @@ -418,13 +493,80 @@ fn build_container( hasher, scanner, thumbnailer, + transcript_repo, + audio_pipeline, + config_dir: config_dir.to_path_buf(), + device_id, }; Ok(( - AppContainer::new(deps, handlers), + AppContainer::new_with_bus(deps, handlers, bus), writer, tag_repo, metadata_repo, search_repo, )) } + +/// Resolve the audio pipeline used by the transcription use-case. +/// +/// T7 wiring strategy: try the Tauri-bundled ffmpeg sidecar first +/// (`app.path().resolve("ffmpeg-{target-triple}", BaseDirectory::Resource)`), +/// fall back to PATH-discovered system ffmpeg via [`which::which`], and last +/// of all wire the deferred-error [`MissingFfmpegPipeline`] so non-transcription +/// commands stay functional. Logging discriminates the three branches so the +/// install state is obvious from `tracing` output. +/// +/// WHY this layered fallback (rather than bundled-only): T12 has not yet +/// landed the per-platform binary copy step, so a release build today has no +/// `ffmpeg-{target-triple}` resource. Falling back to system PATH keeps the +/// transcription pipeline usable in development + on machines where the user +/// has installed ffmpeg via apt/brew/winget, while bundled builds (post-T12) +/// transparently prefer the sidecar. +fn resolve_audio_pipeline( + app: &tauri::AppHandle, +) -> Arc { + // WHY the resource literal name: `tauri build` rewrites + // `bundle.externalBin = ["binaries/ffmpeg"]` to per-target resource files + // named `ffmpeg-{target-triple}` (e.g. `ffmpeg-x86_64-unknown-linux-gnu`). + // T12 will land the actual binaries; T7 only resolves the path so the + // wiring is in place when those binaries appear. TARGET is captured at + // build time by build.rs (see WHY there) — std::env::consts::ARCH would + // give only "x86_64" and never match the sidecar filename. + let target_triple = env!("TARGET"); + let bundled_name = format!("binaries/ffmpeg-{target_triple}"); + if let Ok(bundled_path) = app.path().resolve(&bundled_name, BaseDirectory::Resource) + && bundled_path.exists() + { + tracing::info!( + path = %bundled_path.display(), + "using Tauri-bundled ffmpeg sidecar", + ); + return Arc::new(perima_transcribe::audio::FfmpegAudioPipeline::new( + Arc::new(perima_transcribe::audio::DesktopFfmpegInvoker::new( + bundled_path, + )), + )); + } + match which::which("ffmpeg") { + Ok(system_path) => { + tracing::info!( + path = %system_path.display(), + "ffmpeg sidecar not bundled (T12 pending); falling back to system ffmpeg on PATH", + ); + Arc::new(perima_transcribe::audio::FfmpegAudioPipeline::new( + Arc::new(perima_transcribe::audio::DesktopFfmpegInvoker::new( + system_path, + )), + )) + } + Err(e) => { + tracing::warn!( + error = %e, + "no bundled ffmpeg sidecar AND no system ffmpeg on PATH; \ + transcription will fail with BinaryNotFound until one is provided", + ); + Arc::new(MissingFfmpegPipeline) + } + } +} diff --git a/crates/desktop/src/payloads.rs b/crates/desktop/src/payloads.rs index 90e9de4..3a62d16 100644 --- a/crates/desktop/src/payloads.rs +++ b/crates/desktop/src/payloads.rs @@ -21,6 +21,66 @@ use perima_core::{FileLocationRecord, FileUuid, MediaMetadata, Tag}; use serde::Serialize; +// --------------------------------------------------------------------------- +// Transcription wire-types (T7) +// +// WHY a wire-type mirror for `TranscribeStartedPayload` (not specta-derive on +// `perima_app::TranscribeOutput`): +// +// 1. `TranscribeOutput` is `Debug + Clone` only — no `Serialize`. Adding +// `Serialize` + `specta::Type` to the entire enum would also expose the +// `Cancelled` variant across the IPC boundary, which the `cancel_transcription` +// command does not need (it returns `Result<(), CoreError>`). The +// transcription Tauri commands surface a tightly-scoped IPC API; the wire +// type captures only the fields the frontend actually needs. +// 2. `TranscribeCommand::Start { source: PathBuf, ... }` does not cross the IPC +// boundary at all — the Tauri handler accepts `source: String` (Tauri +// serializes paths as strings) and constructs the `PathBuf` shell-side +// before calling the use-case. So no specta-derive on the command enum is +// needed. +// +// `ListProvidersPayload` + `ProviderListEntry` are shell-private composites: +// they query the OS keyring at construction time to set the `has_key` flag, +// which is a Tauri-shell concern (CLI's `auth list` does the same lookup +// shell-side). No clean core/app analogue exists. +// --------------------------------------------------------------------------- + +/// Returned by the `transcribe` Tauri command — mirror of +/// [`perima_app::TranscribeOutput::Started`]. +#[derive(Debug, Clone, Serialize, specta::Type)] +pub struct TranscribeStartedPayload { + /// Per-request `UUIDv7` (lowercase-hex simple form). Pairs with every + /// `AppEvent::Transcription*` variant for this job. + pub request_uuid: String, + /// 1-based position in the queue at the moment of enqueue. + pub queue_position: u32, +} + +/// Returned by the `list_providers` Tauri command. Combines the TOML config +/// view with a per-provider keyring lookup. +#[derive(Debug, Clone, Serialize, specta::Type)] +pub struct ListProvidersPayload { + /// Active provider name from `[transcription].active_provider`, or `None` + /// when the user has not selected one yet. + pub active: Option, + /// One entry per provider in `[transcription.providers.*]`. + pub providers: Vec, +} + +/// One row in [`ListProvidersPayload::providers`]. +#[derive(Debug, Clone, Serialize, specta::Type)] +pub struct ProviderListEntry { + /// Provider name (the `[transcription.providers.]` key). + pub name: String, + /// Preset name (`groq`, `openai`, `custom`, ...). + pub preset: String, + /// Optional model override; `None` falls back to the preset's default. + pub model: Option, + /// Whether a keyring entry exists for this provider on this device. + /// Computed via [`keyring::Entry::get_password`] at command-handler time. + pub has_key: bool, +} + /// Flattened `(FileLocationRecord, Option)` pair for the /// frontend. /// @@ -88,6 +148,22 @@ pub struct FileWithMetadataPayload { /// Thumbnail lifecycle: `"pending"`, `"ready"`, `"failed"`, or /// `None` if the metadata row predates v0.4.1. pub thumbnail_status: Option, + /// Absolute on-disk path joining the volume's current mount root + /// with [`Self::relative_path`]. `None` when the volume is not + /// currently mounted on this machine. + /// + /// WHY exposed here (T9 review fix): the desktop transcribe + /// command (and any future open-file / preview / export action) + /// must hand ffmpeg / the OS an absolute path — relative paths + /// resolve against the desktop process cwd, which is rarely the + /// volume root, producing silent ENOENT. Pre-fix the frontend was + /// passing `relative_path` to `transcribe`, which forwards to + /// ffmpeg unchanged. + /// + /// WHY `Option`: a volume that is currently unmounted has + /// no `volume_mounts` row → SQL produces NULL → frontend disables + /// the dependent action with a "Volume not mounted" tooltip. + pub absolute_path: Option, } /// File-with-metadata plus its attached tags. @@ -109,9 +185,21 @@ pub struct FileWithTagsPayload { pub tags: Vec, } -impl From<(FileLocationRecord, Option, Option)> for FileWithMetadataPayload { +impl + From<( + FileLocationRecord, + Option, + Option, + Option, + )> for FileWithMetadataPayload +{ fn from( - (loc, meta, quick_hash): (FileLocationRecord, Option, Option), + (loc, meta, quick_hash, mount_path): ( + FileLocationRecord, + Option, + Option, + Option, + ), ) -> Self { // WHY unzip via `meta.map(...)`: the nine optional metadata // fields each need independent `None` defaults when the @@ -149,6 +237,18 @@ impl From<(FileLocationRecord, Option, Option)> for FileW None, None, None, None, None, None, None, None, None, None, None, ), }; + // WHY compute absolute_path here (not in SQL): the join already + // produces `mount_path`; combining with `relative_path` is a + // platform-sensitive `PathBuf::push` that belongs in Rust (SQL + // string concatenation would produce a forward-slash string on + // Windows, breaking ffmpeg + the OS open-file APIs). When + // `mount_path` is None the volume is unmounted — the frontend + // disables the dependent UI control. + let absolute_path = mount_path.as_ref().map(|mp| { + let mut p = std::path::PathBuf::from(mp); + p.push(loc.relative_path.as_str()); + p.to_string_lossy().into_owned() + }); Self { file_uuid: loc.file_uuid, hash: loc.hash.map(|h| h.to_hex()), @@ -169,6 +269,7 @@ impl From<(FileLocationRecord, Option, Option)> for FileW mime_type, thumbnail_path, thumbnail_status, + absolute_path, } } } diff --git a/crates/desktop/tauri.conf.json b/crates/desktop/tauri.conf.json index 531593e..1ee1ad4 100644 --- a/crates/desktop/tauri.conf.json +++ b/crates/desktop/tauri.conf.json @@ -24,5 +24,8 @@ } } }, + "bundle": { + "externalBin": ["binaries/ffmpeg"] + }, "plugins": {} } diff --git a/crates/desktop/tests/bindings_compile.rs b/crates/desktop/tests/bindings_compile.rs index 5906bf5..f39be90 100644 --- a/crates/desktop/tests/bindings_compile.rs +++ b/crates/desktop/tests/bindings_compile.rs @@ -54,6 +54,15 @@ fn build_test_builder() -> Builder { commands::search, commands::search_rebuild, commands::backup_database, + // T7: transcription commands. + commands::transcribe, + commands::cancel_transcription, + commands::set_provider_key, + commands::delete_provider_key, + commands::has_provider_key, + commands::list_providers, + commands::update_transcription_config, + commands::get_transcription_config, ]) } @@ -131,4 +140,19 @@ fn tauri_specta_builder_exports_full_ipc_type_graph() { ts.contains(r#"kind: "TargetExists""#) || ts.contains("kind: \"TargetExists\""), "BackupFailureReason::TargetExists variant missing from bindings" ); + + // T7 transcription wire-types must appear in the export. Wire-type + // mirrors live in `crates/desktop/src/payloads.rs`; the + // `TranscriptionConfig` + `ProviderEntry` types come from + // `perima_app::config::transcription` (specta-derived under the + // `specta` feature, gated by `perima-app/specta`). + for ty in [ + "TranscribeStartedPayload", + "ListProvidersPayload", + "ProviderListEntry", + "TranscriptionConfig", + "ProviderEntry", + ] { + assert!(ts.contains(ty), "{ty} missing from bindings (T7)"); + } } diff --git a/crates/transcribe/Cargo.toml b/crates/transcribe/Cargo.toml new file mode 100644 index 0000000..8ab7eb1 --- /dev/null +++ b/crates/transcribe/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "perima-transcribe" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false # WHY: intra-workspace only; not published to crates.io + +[dependencies] +async-openai = { workspace = true } +flume = { workspace = true } +keyring = { workspace = true } +mime_guess = { workspace = true } +perima-core = { path = "../core" } +# WHY no direct reqwest dep: comes transitively via async-openai. +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "process", "macros", "fs", "io-util"] } +# WHY no features = ["sync"] on tokio-util: CancellationToken is +# unconditionally available in 0.7.18+ (the "sync" feature does not exist). +# T1 confirmed this; T3 applies the same fix. +tokio-util = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +which = { workspace = true } + +[dev-dependencies] +hound = { workspace = true } +proptest = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } +uuid = { workspace = true } +wiremock = { workspace = true } + +[lints] +workspace = true diff --git a/crates/transcribe/src/audio.rs b/crates/transcribe/src/audio.rs new file mode 100644 index 0000000..9aa5314 --- /dev/null +++ b/crates/transcribe/src/audio.rs @@ -0,0 +1,262 @@ +//! Audio extraction shim. Wraps ffmpeg as either a Tauri-bundled +//! sidecar binary (desktop) or a PATH-discovered system binary (CLI). +//! +//! See spec § "Audio extraction — `AudioPipeline`" + § "ffmpeg sourcing +//! strategy". + +use std::io::Read as _; +use std::path::PathBuf; +use std::process::{Child, Stdio}; +use std::sync::Arc; + +use tempfile::NamedTempFile; +use tokio_util::sync::CancellationToken; + +/// Errors from audio extraction. +#[derive(Debug, thiserror::Error)] +pub enum AudioError { + /// ffmpeg binary not found on PATH (CLI) or in the sidecar bundle (desktop). + #[error("ffmpeg binary not found: {0}")] + BinaryNotFound(String), + + /// ffmpeg returned a non-zero exit code. + #[error("ffmpeg exited with status {status}: {stderr}")] + NonZeroExit { + /// Exit status code. + status: i32, + /// Captured stderr for diagnostics. + stderr: String, + }, + + /// I/O failure spawning or reading from ffmpeg. + #[error("ffmpeg I/O failure: {0}")] + Io(#[from] std::io::Error), + + /// Cancelled by caller via [`CancellationToken`]. + #[error("audio extraction cancelled by caller")] + Cancelled, +} + +/// Audio extraction operations used by transcription adapters. +pub trait AudioPipeline: Send + Sync { + /// Drop video stream, downmix to mono Opus 32 kbps, return a [`NamedTempFile`] + /// that auto-deletes on Drop. Used by cloud adapters before upload. + /// Cancel-aware: kills the ffmpeg child if `cancel` fires. + /// + /// # Errors + /// Returns [`AudioError`] for ffmpeg failures. + fn remux_for_upload( + &self, + input: &std::path::Path, + cancel: &CancellationToken, + ) -> Result; + + // Future (local slice, V013): extract_pcm_16k_mono(...) +} + +/// Strategy for invoking ffmpeg. Two impls: one for desktop (uses Tauri's +/// path resolver to locate the bundled sidecar binary), one for CLI +/// (uses [`which`]). +pub trait FfmpegInvoker: Send + Sync { + /// Spawn `ffmpeg ` and return a handle whose Drop kills the + /// child. Output streamed to the returned reader; stderr captured + /// for error context. + /// + /// # Errors + /// Returns [`std::io::Error`] for spawn failures. + fn spawn(&self, args: &[&str]) -> std::io::Result; +} + +/// Handle to a running ffmpeg child. Drop kills the child. +#[derive(Debug)] +pub struct FfmpegChild { + /// The underlying OS process. `Option` so `wait_with_output` can `take` + /// ownership while `Drop` can still observe `None` and skip the kill. + pub(crate) child: Option, +} + +impl FfmpegChild { + /// Construct from a spawned `Child`. + #[must_use] + pub const fn new(child: Child) -> Self { + Self { child: Some(child) } + } + + /// Wait for the child to exit; return its [`std::process::Output`] and captured stderr. + /// + /// # Panics + /// Panics if called more than once (the child handle is moved on the first + /// call; subsequent calls would have nothing to wait on). + /// + /// # Errors + /// I/O errors waiting on the child. + pub fn wait_with_output(mut self) -> std::io::Result { + let child = self.child.take().expect("child already taken"); + child.wait_with_output() + } + + /// Kill the child immediately. + /// + /// # Errors + /// I/O errors while killing. + pub fn kill(&mut self) -> std::io::Result<()> { + if let Some(child) = self.child.as_mut() { + child.kill()?; + } + Ok(()) + } +} + +impl Drop for FfmpegChild { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + // Best-effort kill on drop; ignore errors (process may have already exited). + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Desktop ffmpeg invoker: holds the absolute path to the bundled sidecar +/// binary, resolved at app startup via `app.path().resolve(...)`. +#[derive(Debug)] +pub struct DesktopFfmpegInvoker { + binary_path: PathBuf, +} + +impl DesktopFfmpegInvoker { + /// Construct from a pre-resolved binary path (typically from + /// `app.path().resolve("ffmpeg-{target-triple}", BaseDirectory::Resource)`). + #[must_use] + pub const fn new(binary_path: PathBuf) -> Self { + Self { binary_path } + } +} + +impl FfmpegInvoker for DesktopFfmpegInvoker { + fn spawn(&self, args: &[&str]) -> std::io::Result { + let child = std::process::Command::new(&self.binary_path) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + Ok(FfmpegChild::new(child)) + } +} + +/// CLI ffmpeg invoker: holds the PATH-discovered binary location. +#[derive(Debug)] +pub struct CliFfmpegInvoker { + binary_path: PathBuf, +} + +impl CliFfmpegInvoker { + /// Discover ffmpeg on PATH via [`which::which`]. Caches the resolved path. + /// + /// # Errors + /// Returns [`AudioError::BinaryNotFound`] if ffmpeg is not on PATH. + pub fn discover() -> Result { + let path = which::which("ffmpeg").map_err(|_| { + AudioError::BinaryNotFound( + "ffmpeg not found on PATH; install via apt/brew/winget or use the desktop app" + .to_owned(), + ) + })?; + Ok(Self { binary_path: path }) + } +} + +impl FfmpegInvoker for CliFfmpegInvoker { + fn spawn(&self, args: &[&str]) -> std::io::Result { + let child = std::process::Command::new(&self.binary_path) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + Ok(FfmpegChild::new(child)) + } +} + +/// [`AudioPipeline`] impl built on a generic [`FfmpegInvoker`]. +#[derive(Debug)] +pub struct FfmpegAudioPipeline { + // WHY Arc not owned: the use-case may construct one invoker at app + // startup and share it across multiple pipeline instances (per-job + // pipelines + a future health-check probe). Owning `I` would force + // one invoker per pipeline instance and duplicate the binary-path + // resolution. Cheap clone on dispatch is the right tradeoff. + invoker: Arc, +} + +impl FfmpegAudioPipeline { + /// Construct from an invoker. Caller chooses Desktop vs Cli at app startup. + #[must_use] + pub const fn new(invoker: Arc) -> Self { + Self { invoker } + } +} + +impl AudioPipeline for FfmpegAudioPipeline { + fn remux_for_upload( + &self, + input: &std::path::Path, + cancel: &CancellationToken, + ) -> Result { + let output = NamedTempFile::with_suffix(".opus")?; + let output_path = output.path().to_owned(); + + let input_str = input.to_str().ok_or_else(|| { + AudioError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "input path is not valid UTF-8", + )) + })?; + let output_str = output_path.to_str().ok_or_else(|| { + AudioError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "output tempfile path is not valid UTF-8", + )) + })?; + + // WHY this flag order: -y (overwrite), -i (input), -vn (no video), + // -ac 1 (downmix to mono), -c:a libopus (codec), -b:a 32k (bitrate). + // Produces an Opus file that Whisper backends accept and is well + // under the 25 MiB file-size ceiling even for hour-long media. + let args = [ + "-y", "-i", input_str, "-vn", "-ac", "1", "-c:a", "libopus", "-b:a", "32k", output_str, + ]; + + let mut child = self.invoker.spawn(&args)?; + + // WHY poll-and-sleep (50 ms) rather than tokio::process::Command + + // select!: the port trait is sync (no async fn), and spinning up a + // nested tokio runtime inside a sync fn violates the one-runtime-per- + // binary rule. A future local-adapter slice can move this to an async + // fn on a dedicated worker thread. + loop { + if cancel.is_cancelled() { + let _ = child.kill(); + return Err(AudioError::Cancelled); + } + match child.child.as_mut().expect("child taken").try_wait()? { + Some(status) => { + if status.success() { + return Ok(output); + } + let mut stderr = String::new(); + if let Some(err_pipe) = child.child.as_mut().and_then(|c| c.stderr.take()) { + let mut reader = err_pipe; + let _ = reader.read_to_string(&mut stderr); + } + return Err(AudioError::NonZeroExit { + status: status.code().unwrap_or(-1), + stderr, + }); + } + None => std::thread::sleep(std::time::Duration::from_millis(50)), + } + } + } +} diff --git a/crates/transcribe/src/lib.rs b/crates/transcribe/src/lib.rs new file mode 100644 index 0000000..51880e3 --- /dev/null +++ b/crates/transcribe/src/lib.rs @@ -0,0 +1,16 @@ +//! Transcription adapters for perima. +//! +//! - [`audio`] — ffmpeg-based audio extraction shim (`AudioPipeline` trait). +//! - [`providers`] — preset table for known OpenAI-compatible providers. +//! - [`registry`] — runtime registry of constructed [`perima_core::transcription::Transcriber`] impls. +//! - [`openai_compat`] — single cloud adapter wrapping `async-openai` for all +//! `OpenAI` `/v1/audio/transcriptions`-compatible providers. +//! +//! See spec `docs/superpowers/specs/2026-05-02-transcription-v1-design.md`. + +#![cfg_attr(test, allow(clippy::unwrap_used))] + +pub mod audio; +pub mod openai_compat; +pub mod providers; +pub mod registry; diff --git a/crates/transcribe/src/openai_compat.rs b/crates/transcribe/src/openai_compat.rs new file mode 100644 index 0000000..99b5731 --- /dev/null +++ b/crates/transcribe/src/openai_compat.rs @@ -0,0 +1,333 @@ +//! `OpenAI`-compatible cloud transcription adapter. +//! +//! Single Rust adapter covers all providers serving the `OpenAI` +//! `/v1/audio/transcriptions` wire format (Groq, `OpenAI`, +//! faster-whisper-server, `vLLM`, Whisperfile, `OpenWebUI`, vox-box, +//! Azure-via-LiteLLM). +//! See spec § "The cloud adapter — `OpenAICompatibleTranscriber`". + +use std::path::PathBuf; +use std::sync::Arc; + +use async_openai::Client; +use async_openai::config::OpenAIConfig; +use async_openai::error::OpenAIError; +use async_openai::types::audio::{ + AudioResponseFormat, CreateTranscriptionRequestArgs, TimestampGranularity, +}; +use tokio::runtime::{Handle, RuntimeFlavor}; +use uuid::Uuid; + +use perima_core::CoreError; +use perima_core::transcription::{ + BackendId, TranscribeRequest, Transcriber, TranscriptSegment, TranscriptionError, + TranscriptionProgress, TranscriptionResult, +}; + +use crate::audio::AudioPipeline; +use crate::providers::{AuthScheme, ProviderPreset}; + +/// One OpenAI-compatible cloud transcription backend. +/// +/// Wraps an `async-openai` client with a configurable `base_url`, +/// `auth_scheme`, and `model`. The sync [`Transcriber::transcribe`] method +/// bridges into the adapter's async machinery via +/// `tokio::task::block_in_place + Handle::block_on`, so the calling +/// thread blocks for the duration of the request without spawning a +/// second tokio runtime. +pub struct OpenAICompatibleTranscriber { + id: BackendId, + client: Client, + model: String, + runtime: Handle, + audio: Arc, + file_size_limit_bytes: u64, +} + +// WHY manual Debug: `Client` does not derive `Debug` and +// `Arc` is also not `Debug`. Elide both so the +// surrounding container types (e.g. `AppContainer`) can still derive Debug. +impl std::fmt::Debug for OpenAICompatibleTranscriber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenAICompatibleTranscriber") + .field("id", &self.id) + .field("model", &self.model) + .field("file_size_limit_bytes", &self.file_size_limit_bytes) + .finish_non_exhaustive() + } +} + +impl OpenAICompatibleTranscriber { + /// Build a new adapter. + /// + /// # Panics + /// Panics if the supplied tokio runtime handle is `current_thread`. + /// `block_in_place` requires the multi-thread scheduler — it would + /// panic on first transcribe otherwise. See spec § "Sync→async bridge + /// contract". + /// + /// # Errors + /// Currently infallible; `Result` reserved for future preset validation + /// (e.g. `AuthScheme::None` + non-localhost `base_url`). + pub fn new( + preset: &ProviderPreset, + api_key: String, + model: Option, + runtime: Handle, + audio: Arc, + ) -> Result { + // Fail loud + early on current_thread runtime — block_in_place would + // panic on first transcribe otherwise. Per spec § "Sync→async bridge + // contract". + match runtime.runtime_flavor() { + RuntimeFlavor::MultiThread => {} + other => panic!( + "OpenAICompatibleTranscriber requires a multi-thread tokio runtime; \ + got {other:?}. block_in_place + block_on would deadlock on \ + current_thread. See spec § 'Sync→async bridge contract'." + ), + } + + let model = model.unwrap_or_else(|| preset.default_model.to_owned()); + let id = BackendId(format!("{}:{}", preset.name, model)); + + // Per AuthScheme: + // - Bearer: async-openai's default; pass api_key through. + // - XApiKey { header }: emit `
: ` via with_header. + // async-openai 0.36 still emits an `Authorization: Bearer ` line + // with an empty key alongside, but custom-header-using providers + // (Azure, LiteLLM) ignore the unrecognized auth. + // - None: pass an empty key (some local servers ignore the header). + let mut config = OpenAIConfig::new().with_api_base(preset.base_url); + match preset.auth_scheme { + AuthScheme::Bearer => { + config = config.with_api_key(api_key); + } + AuthScheme::XApiKey { header } => { + config = config.with_header(header, api_key).map_err(|e| { + CoreError::Transcription(TranscriptionError::Internal(format!( + "invalid XApiKey header value for provider {}: {e}", + preset.name + ))) + })?; + } + AuthScheme::None => {} + } + + let client = Client::with_config(config); + Ok(Self { + id, + client, + model, + runtime, + audio, + file_size_limit_bytes: preset.file_size_limit_bytes, + }) + } + + async fn transcribe_inner( + &self, + upload_path: PathBuf, + language_hint: Option, + ) -> Result { + let mut builder = CreateTranscriptionRequestArgs::default(); + // WHY .file(upload_path): the derive_builder setter uses `into`, + // and async-openai 0.36 implements `From>` for + // `AudioInput` (see types/impls.rs::impl_input!), so a PathBuf + // converts straight in. + builder + .file(upload_path) + .model(self.model.clone()) + .response_format(AudioResponseFormat::VerboseJson) + .timestamp_granularities(vec![TimestampGranularity::Segment]); + if let Some(lang) = language_hint { + builder.language(lang); + } + let request = builder.build().map_err(|e| { + CoreError::Transcription(TranscriptionError::Internal(format!("request build: {e}"))) + })?; + + // WHY .audio().transcription().create_verbose_json: async-openai 0.36 + // names this differently than spec/plan (which referenced a 0.32-era + // `audio().transcribe_verbose_json` shortcut). 0.36 splits the audio + // group into nested `Audio -> Transcriptions -> create_verbose_json`. + let response = self + .client + .audio() + .transcription() + .create_verbose_json(request) + .await + .map_err(|e| { + map_async_openai_error(e, &self.id, &self.model, self.file_size_limit_bytes) + })?; + + let segments = response + .segments + .unwrap_or_default() + .into_iter() + .map(|s| { + // WHY explicit f32 casts: TranscriptionSegment fields are + // f32 in 0.36 (not f64 as the plan draft assumed); seconds * + // 1000 fits well within u32 for any realistic media file + // (u32::MAX ms ≈ 49 days), so the lossy as-cast is safe. + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let start_ms = (s.start * 1000.0) as u32; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let end_ms = (s.end * 1000.0) as u32; + TranscriptSegment { + // WHY nil placeholder: the use-case stamps a real UUIDv7 + // before the row is built (TranscriptionUseCase walks the + // returned segments and replaces every nil id). Keeping + // the adapter UUID-free avoids an extra `uuid::Uuid::now_v7()` + // call per segment in the cloud hot path. + id: Uuid::nil(), + start_ms, + end_ms, + text: s.text, + // WHY None for v1: avg_logprob is a quality signal not a + // calibrated probability; mapping it to a confidence in + // [0.0, 1.0] needs separate calibration. Local adapters + // will surface real confidences in a later slice. + confidence: None, + } + }) + .collect(); + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let duration_ms = (response.duration * 1000.0) as u32; + Ok(TranscriptionResult { + language: Some(response.language), + duration_ms, + segments, + backend: self.id.clone(), + }) + } +} + +impl Transcriber for OpenAICompatibleTranscriber { + fn id(&self) -> &BackendId { + &self.id + } + + fn accepts(&self, mime: &str) -> bool { + mime.starts_with("audio/") || mime.starts_with("video/") + } + + fn transcribe(&self, req: &TranscribeRequest) -> Result { + // 1. Pre-flight remux if file > limit OR has a video MIME. + let metadata = std::fs::metadata(&req.source).map_err(|e| { + CoreError::Transcription(TranscriptionError::AudioDecode(format!("stat input: {e}"))) + })?; + let needs_remux = metadata.len() > self.file_size_limit_bytes + || mime_guess::from_path(&req.source) + .first_or_octet_stream() + .type_() + == "video"; + + // WHY hold the NamedTempFile in a binding rather than mem::forget + + // manual remove_file: tempfile's Drop is the canonical cleanup path + // and the plan's manual remove_file/forget pair is a leak-on-panic + // hazard. Holding `_remux_handle` in scope keeps the file alive until + // the function returns; Drop fires on both Ok and Err paths. + let (upload_path, _remux_handle) = if needs_remux { + let temp = self + .audio + .remux_for_upload(&req.source, &req.cancel) + .map_err(|e| match e { + // Preserve the cancel signal end-to-end — folding it into + // AudioDecode would surface as a confusing decode error in + // the UI even though the user just hit Cancel. + crate::audio::AudioError::Cancelled => { + CoreError::Transcription(TranscriptionError::Cancelled) + } + other => CoreError::Transcription(TranscriptionError::AudioDecode(format!( + "remux: {other}" + ))), + })?; + (temp.path().to_owned(), Some(temp)) + } else { + (req.source.clone(), None) + }; + + (req.on_progress)(TranscriptionProgress::Started { + estimated_duration_ms: None, + }); + + // 2. block_in_place + block_on bridge. Required because the trait is + // sync but async-openai exposes only async methods. block_in_place + // is panic-banned on current_thread; the constructor's flavor + // check guards that. + let result = tokio::task::block_in_place(|| { + self.runtime + .block_on(self.transcribe_inner(upload_path, req.language_hint.clone())) + }); + + (req.on_progress)(TranscriptionProgress::Finished); + result + } +} + +/// Map `async-openai` errors to typed [`TranscriptionError`] variants. +/// +/// Covered by `crates/transcribe/tests/error_mapping.rs` (variant + +/// field assertions, not insta snapshots). +/// +/// The match covers every concrete `OpenAIError` variant in 0.36 (no +/// wildcard arm), so adding a new variant in a future bump becomes a +/// compile error here. +#[must_use] +pub fn map_async_openai_error( + err: OpenAIError, + backend: &BackendId, + model: &str, + file_size_limit_bytes: u64, +) -> CoreError { + let te = match err { + OpenAIError::ApiError(api) => map_api_error(&api, backend, model), + OpenAIError::Reqwest(e) => TranscriptionError::Network(e.to_string()), + OpenAIError::JSONDeserialize(e, body) => { + TranscriptionError::Internal(format!("deserialize response: {e} (body: {body})")) + } + OpenAIError::FileSaveError(s) | OpenAIError::FileReadError(s) => { + TranscriptionError::AudioDecode(s) + } + OpenAIError::StreamError(s) => TranscriptionError::Network(s.to_string()), + // Heuristic: builder/preflight errors mentioning size hit the + // backend's file-size ceiling. Any other InvalidArgument is an + // adapter-internal bug (caller passed a malformed request). + OpenAIError::InvalidArgument(s) if s.to_lowercase().contains("size") => { + TranscriptionError::FileTooLarge { + limit_bytes: file_size_limit_bytes, + } + } + OpenAIError::InvalidArgument(s) => TranscriptionError::Internal(s), + }; + CoreError::Transcription(te) +} + +fn map_api_error( + api: &async_openai::error::ApiError, + backend: &BackendId, + model: &str, +) -> TranscriptionError { + match api.code.as_deref() { + Some("invalid_api_key" | "unauthorized") => TranscriptionError::Auth, + Some("quota_exceeded" | "billing_hard_limit_reached" | "insufficient_quota") => { + TranscriptionError::QuotaExceeded + } + Some("model_not_found") => TranscriptionError::ModelNotFound { + backend: backend.0.clone(), + model: model.to_owned(), + }, + Some("rate_limit_exceeded") => TranscriptionError::RateLimited { + // async-openai 0.36's ApiError doesn't surface response headers, + // so we cannot read Retry-After here. Adapters that need the + // hint will have to bypass `Client::audio()` and inspect the + // raw response — out of scope for v1. + retry_after_secs: None, + }, + _ => TranscriptionError::BackendUnavailable { + reason: format!("API error: {} ({:?})", api.message, api.code), + }, + } +} diff --git a/crates/transcribe/src/providers.rs b/crates/transcribe/src/providers.rs new file mode 100644 index 0000000..7acd900 --- /dev/null +++ b/crates/transcribe/src/providers.rs @@ -0,0 +1,75 @@ +//! Preset table for known OpenAI-compatible STT providers. +//! +//! See spec § "The cloud adapter — `OpenAICompatibleTranscriber`". + +/// Auth scheme for an HTTP-based STT backend. +#[derive(Debug, Clone, Copy)] +pub enum AuthScheme { + /// `Authorization: Bearer `. + Bearer, + /// Custom header (e.g. `api-key: ` for Azure). + XApiKey { + /// The header name to use (e.g. `"api-key"`). + header: &'static str, + }, + /// No auth (used for local self-hosted servers in dev). + None, +} + +/// Static preset for a known provider. +#[derive(Debug, Clone, Copy)] +pub struct ProviderPreset { + /// Stable provider name used in `BackendId` and config TOML. + pub name: &'static str, + /// Default API base URL. + pub base_url: &'static str, + /// Default model name when user doesn't override. + pub default_model: &'static str, + /// Backend's file-size ceiling in bytes (audio post-remux must fit). + pub file_size_limit_bytes: u64, + /// Auth header shape. + pub auth_scheme: AuthScheme, +} + +/// All bundled providers. See spec § "The cloud adapter" for the rationale +/// (one OpenAI-compat adapter + `base_url` covers all known servers in 2026). +/// +/// **Adding a new provider:** insert a new entry BEFORE the `"custom"` +/// entry (which must remain last so callers can rely on +/// `KNOWN_PROVIDERS.last()` being the user-customisable template). +/// `find_preset` is O(N); fine for N≈3-10 providers. +pub const KNOWN_PROVIDERS: &[ProviderPreset] = &[ + ProviderPreset { + name: "groq", + base_url: "https://api.groq.com/openai/v1", + default_model: "whisper-large-v3-turbo", + // WHY 24 MiB not 25 MiB: Groq's stated limit is 25 MiB but their + // parser rejects files at exactly the limit due to multipart overhead. + // A 1 MiB headroom eliminates the edge case without meaningful loss. + file_size_limit_bytes: 24 * 1024 * 1024, + auth_scheme: AuthScheme::Bearer, + }, + ProviderPreset { + name: "openai", + base_url: "https://api.openai.com/v1", + default_model: "whisper-1", + file_size_limit_bytes: 24 * 1024 * 1024, + auth_scheme: AuthScheme::Bearer, + }, + ProviderPreset { + // WHY empty base_url + default_model: this is a TEMPLATE entry. The T5 + // config loader MUST overlay user-supplied values before this preset is + // used to construct an adapter. Direct use will fail at the HTTP layer. + name: "custom", + base_url: "", + default_model: "", + file_size_limit_bytes: 24 * 1024 * 1024, + auth_scheme: AuthScheme::Bearer, + }, +]; + +/// Lookup a preset by name. +#[must_use] +pub fn find_preset(name: &str) -> Option<&'static ProviderPreset> { + KNOWN_PROVIDERS.iter().find(|p| p.name == name) +} diff --git a/crates/transcribe/src/registry.rs b/crates/transcribe/src/registry.rs new file mode 100644 index 0000000..05aeaa0 --- /dev/null +++ b/crates/transcribe/src/registry.rs @@ -0,0 +1,92 @@ +//! Runtime registry of constructed [`Transcriber`] impls, keyed by +//! [`BackendId`]. Built once at app startup from `TranscriptionConfig`. + +use std::collections::HashMap; +use std::sync::Arc; + +use perima_core::CoreError; +use perima_core::transcription::{BackendId, Transcriber, TranscriptionError}; + +/// Registry of available transcribers. The use-case looks up the active +/// backend by name and dispatches. +pub struct TranscriberRegistry { + backends: HashMap>, + active: Option, +} + +impl std::fmt::Debug for TranscriberRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // WHY manual Debug: Arc is not Debug; elide backends map. + f.debug_struct("TranscriberRegistry") + .field("backend_count", &self.backends.len()) + .field("active", &self.active) + .finish() + } +} + +impl TranscriberRegistry { + /// Construct an empty registry. Use [`Self::register`] to add backends + /// and [`Self::set_active`] to mark one as default. + #[must_use] + pub fn new() -> Self { + Self { + backends: HashMap::new(), + active: None, + } + } + + /// Register a transcriber under its self-reported [`BackendId`]. + pub fn register(&mut self, backend: Arc) { + let id = backend.id().clone(); + self.backends.insert(id, backend); + } + + /// Mark a backend as the default. The [`BackendId`] must already be + /// registered. + /// + /// # Errors + /// Returns [`CoreError::Transcription`] wrapping + /// [`TranscriptionError::BackendUnavailable`] if the id is not registered. + pub fn set_active(&mut self, id: BackendId) -> Result<(), CoreError> { + if !self.backends.contains_key(&id) { + return Err(CoreError::Transcription( + TranscriptionError::BackendUnavailable { + reason: format!("provider {id} is not registered"), + }, + )); + } + self.active = Some(id); + Ok(()) + } + + /// Get the active transcriber. + /// + /// # Errors + /// Returns [`CoreError::Transcription`] wrapping + /// [`TranscriptionError::BackendUnavailable`] if no active backend is configured. + pub fn active(&self) -> Result, CoreError> { + let id = self.active.as_ref().ok_or_else(|| { + CoreError::Transcription(TranscriptionError::BackendUnavailable { + reason: "no active transcription provider configured".to_owned(), + }) + })?; + let backend = self.backends.get(id).ok_or_else(|| { + CoreError::Transcription(TranscriptionError::BackendUnavailable { + reason: format!("active provider {id} disappeared from registry"), + }) + })?; + Ok(Arc::clone(backend)) + } + + /// Lookup a specific backend by id (used for `--provider` CLI override). + #[must_use] + pub fn get(&self, id: &BackendId) -> Option> { + self.backends.get(id).map(Arc::clone) + } +} + +impl Default for TranscriberRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/transcribe/tests/audio_pipeline.rs b/crates/transcribe/tests/audio_pipeline.rs new file mode 100644 index 0000000..ec1883d --- /dev/null +++ b/crates/transcribe/tests/audio_pipeline.rs @@ -0,0 +1,135 @@ +//! Mock-ffmpeg unit tests for the audio pipeline. +//! +//! WHY `#![cfg(unix)]`: the mock invokers spawn POSIX coreutils (`true`, +//! `false`, `sleep 30`) to simulate ffmpeg success / failure / hang. A +//! Windows port would need to spawn equivalents (`cmd /c exit 0`, +//! `cmd /c exit 1`, `timeout /t 30 /nobreak`) — deferred until the +//! Windows ffmpeg-sidecar bundling slice (tracked in ASR v2 milestone). +#![cfg(unix)] +#![allow(clippy::unwrap_used)] // WHY: test code; panics are acceptable assertions. + +use std::sync::{Arc, Mutex}; + +use tokio_util::sync::CancellationToken; + +use perima_transcribe::audio::{ + AudioError, AudioPipeline, FfmpegAudioPipeline, FfmpegChild, FfmpegInvoker, +}; + +/// Records the args passed and spawns a real process (true/false) to simulate +/// success or failure based on a config flag. +struct MockFfmpeg { + captured_args: Mutex>, + fail_with_status: Option, +} + +impl MockFfmpeg { + const fn new(fail_with_status: Option) -> Self { + Self { + captured_args: Mutex::new(Vec::new()), + fail_with_status, + } + } + + fn captured(&self) -> Vec { + self.captured_args.lock().unwrap().clone() + } +} + +impl FfmpegInvoker for MockFfmpeg { + fn spawn(&self, args: &[&str]) -> std::io::Result { + self.captured_args + .lock() + .unwrap() + .extend(args.iter().map(|s| (*s).to_string())); + + // Simulate success with `true` or failure with `false` (exit 1). + let cmd = if self.fail_with_status.is_none() { + "true" + } else { + "false" + }; + let child = std::process::Command::new(cmd) + .stderr(std::process::Stdio::piped()) + .spawn()?; + Ok(FfmpegChild::new(child)) + } +} + +#[test] +fn remux_for_upload_passes_canonical_flags() { + let mock = Arc::new(MockFfmpeg::new(None)); + let pipeline = FfmpegAudioPipeline::new(Arc::clone(&mock)); + let input = std::env::temp_dir().join("test-input.mp4"); + std::fs::write(&input, b"placeholder").unwrap(); + + let cancel = CancellationToken::new(); + let _result = pipeline.remux_for_upload(&input, &cancel).unwrap(); + + let args = mock.captured(); + assert!( + args.contains(&"-vn".to_string()), + "expected -vn in args: {args:?}" + ); + assert!( + args.contains(&"-ac".to_string()) && args.contains(&"1".to_string()), + "expected -ac 1 in args: {args:?}" + ); + assert!( + args.contains(&"-c:a".to_string()) && args.contains(&"libopus".to_string()), + "expected -c:a libopus in args: {args:?}" + ); + assert!( + args.contains(&"-b:a".to_string()) && args.contains(&"32k".to_string()), + "expected -b:a 32k in args: {args:?}" + ); + assert!( + args.contains(&"-i".to_string()), + "expected -i in args: {args:?}" + ); + assert!( + args.contains(&input.to_string_lossy().to_string()), + "expected input path in args: {args:?}" + ); +} + +#[test] +fn remux_returns_non_zero_exit_when_ffmpeg_fails() { + let mock = Arc::new(MockFfmpeg::new(Some(1))); + let pipeline = FfmpegAudioPipeline::new(Arc::clone(&mock)); + let input = std::env::temp_dir().join("test-input-fail.mp4"); + std::fs::write(&input, b"placeholder").unwrap(); + + let cancel = CancellationToken::new(); + let result = pipeline.remux_for_upload(&input, &cancel); + + assert!(matches!(result, Err(AudioError::NonZeroExit { .. }))); +} + +#[test] +fn remux_aborts_on_cancel() { + // Use `sleep 30` to simulate a long-running ffmpeg invocation. + struct SlowMock; + impl FfmpegInvoker for SlowMock { + fn spawn(&self, _args: &[&str]) -> std::io::Result { + let child = std::process::Command::new("sleep") + .arg("30") + .stderr(std::process::Stdio::piped()) + .spawn()?; + Ok(FfmpegChild::new(child)) + } + } + let pipeline = FfmpegAudioPipeline::new(Arc::new(SlowMock)); + let input = std::env::temp_dir().join("test-input-slow.mp4"); + std::fs::write(&input, b"placeholder").unwrap(); + + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + cancel_clone.cancel(); + }); + + let result = pipeline.remux_for_upload(&input, &cancel); + assert!(matches!(result, Err(AudioError::Cancelled))); +} diff --git a/crates/transcribe/tests/error_mapping.rs b/crates/transcribe/tests/error_mapping.rs new file mode 100644 index 0000000..efb9efd --- /dev/null +++ b/crates/transcribe/tests/error_mapping.rs @@ -0,0 +1,135 @@ +//! Snapshot tests for the HTTP-error → `TranscriptionError` mapping. +//! +//! Each test constructs a representative `async-openai` error and asserts +//! the mapped [`perima_core::transcription::TranscriptionError`] variant. +//! Mapping is the seam between the upstream HTTP wire format and the +//! typed error surface the use-case + frontend rely on; a careless +//! implementer flipping arms here would degrade UX silently. Cover all +//! 5 spec'd discriminants (`Auth`, `RateLimited`, `QuotaExceeded`, +//! `ModelNotFound`, `BackendUnavailable`) explicitly. + +#![allow(clippy::unwrap_used)] + +use async_openai::error::{ApiError, OpenAIError}; + +use perima_core::CoreError; +use perima_core::transcription::{BackendId, TranscriptionError}; +use perima_transcribe::openai_compat::map_async_openai_error; + +const FILE_LIMIT_BYTES: u64 = 25_000_000; + +fn backend() -> BackendId { + BackendId("test:model-x".to_owned()) +} + +fn api_err_with_code(code: &str, message: &str) -> OpenAIError { + OpenAIError::ApiError(ApiError { + message: message.to_owned(), + r#type: None, + param: None, + code: Some(code.to_owned()), + }) +} + +#[test] +fn auth_error_maps_to_auth_variant() { + let err = api_err_with_code("invalid_api_key", "Incorrect API key provided"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + assert!(matches!( + mapped, + CoreError::Transcription(TranscriptionError::Auth) + )); +} + +#[test] +fn rate_limit_maps_to_rate_limited_with_no_retry_after() { + let err = api_err_with_code("rate_limit_exceeded", "Rate limit exceeded"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + match mapped { + CoreError::Transcription(TranscriptionError::RateLimited { retry_after_secs }) => { + // async-openai 0.36's ApiError has no header surface — see + // map_api_error WHY-block. Assert None explicitly so any future + // change that starts populating retry_after gets a test signal. + assert_eq!(retry_after_secs, None); + } + other => panic!("wrong variant: {other:?}"), + } +} + +#[test] +fn quota_maps_to_quota_exceeded() { + let err = api_err_with_code("quota_exceeded", "billing limit hit"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + assert!(matches!( + mapped, + CoreError::Transcription(TranscriptionError::QuotaExceeded) + )); +} + +#[test] +fn model_not_found_maps_with_backend_and_model_strings() { + let err = api_err_with_code("model_not_found", "no such model"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + match mapped { + CoreError::Transcription(TranscriptionError::ModelNotFound { backend, model }) => { + assert_eq!(backend, "test:model-x"); + assert_eq!(model, "model-x"); + } + other => panic!("wrong variant: {other:?}"), + } +} + +#[test] +fn unknown_api_error_maps_to_backend_unavailable_with_message_and_code() { + let err = api_err_with_code("server_error", "internal server error"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + match mapped { + CoreError::Transcription(TranscriptionError::BackendUnavailable { reason }) => { + assert!(reason.contains("internal server error"), "got {reason}"); + assert!(reason.contains("server_error"), "got {reason}"); + } + other => panic!("wrong variant: {other:?}"), + } +} + +#[test] +fn billing_hard_limit_maps_to_quota_exceeded() { + // Sibling code that should also classify as QuotaExceeded — covers the + // |-or arm in map_api_error so a later split into separate variants + // doesn't silently regress. + let err = api_err_with_code("billing_hard_limit_reached", "you are out of credits"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + assert!(matches!( + mapped, + CoreError::Transcription(TranscriptionError::QuotaExceeded) + )); +} + +#[test] +fn unauthorized_code_also_maps_to_auth() { + // OpenAI sometimes returns "unauthorized" instead of "invalid_api_key"; + // both must collapse to Auth so frontend UX is consistent. + let err = api_err_with_code("unauthorized", "Missing or invalid API key"); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + assert!(matches!( + mapped, + CoreError::Transcription(TranscriptionError::Auth) + )); +} + +#[test] +fn invalid_argument_with_size_maps_to_file_too_large() { + // Heuristic: async-openai surfaces upstream "request body too large" + // as InvalidArgument with the substring "size" — map to FileTooLarge + // so the UI can surface a concrete byte ceiling rather than a generic + // adapter error. + let err = + OpenAIError::InvalidArgument("request body too large: SIZE limit exceeded".to_owned()); + let mapped = map_async_openai_error(err, &backend(), "model-x", FILE_LIMIT_BYTES); + match mapped { + CoreError::Transcription(TranscriptionError::FileTooLarge { limit_bytes }) => { + assert_eq!(limit_bytes, FILE_LIMIT_BYTES); + } + other => panic!("wrong variant: {other:?}"), + } +} diff --git a/crates/transcribe/tests/openai_compat.rs b/crates/transcribe/tests/openai_compat.rs new file mode 100644 index 0000000..44b63a7 --- /dev/null +++ b/crates/transcribe/tests/openai_compat.rs @@ -0,0 +1,262 @@ +//! End-to-end-ish tests for [`OpenAICompatibleTranscriber`] using wiremock +//! to stub the upstream HTTP server. +//! +//! Multi-thread runtime required: the adapter uses `block_in_place` + +//! `Handle::block_on` to bridge the sync `Transcriber` trait to the async +//! `async-openai` API; `block_in_place` panics on the current-thread +//! scheduler, and the constructor's flavor check rejects it loudly. +//! +//! Because the trait method itself is sync, we wrap each call in +//! `tokio::task::spawn_blocking` so the adapter's internal `block_on` +//! has its own thread to park. + +#![allow(clippy::unwrap_used)] +#![allow(clippy::print_stdout)] + +use std::sync::Arc; +use std::time::Duration; + +use tokio_util::sync::CancellationToken; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use perima_core::CoreError; +use perima_core::transcription::{ + TranscribeRequest, Transcriber, TranscriptionError, TranscriptionProgress, +}; +use perima_transcribe::audio::{AudioError, AudioPipeline}; +use perima_transcribe::openai_compat::OpenAICompatibleTranscriber; +use perima_transcribe::providers::{AuthScheme, ProviderPreset}; + +/// Audio pipeline that just copies its input to a tempfile. Lets us assert +/// the wiremock body without actually invoking ffmpeg. +struct StubAudioPipeline; + +impl AudioPipeline for StubAudioPipeline { + fn remux_for_upload( + &self, + input: &std::path::Path, + _cancel: &CancellationToken, + ) -> Result { + let mut output = tempfile::NamedTempFile::with_suffix(".opus").unwrap(); + std::io::copy(&mut std::fs::File::open(input).unwrap(), &mut output).unwrap(); + Ok(output) + } +} + +/// Synthesize a 100 ms 16 kHz mono silent WAV via hound. Returned path +/// lives in the OS temp dir; tests that care about cleanup can remove it, +/// but for a 3.2 KiB file it's not worth the ceremony. +fn small_test_audio() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("transcribe-test-{}.wav", uuid::Uuid::now_v7())); + let spec = hound::WavSpec { + channels: 1, + sample_rate: 16_000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for _ in 0..1_600 { + writer.write_sample(0_i16).unwrap(); + } + writer.finalize().unwrap(); + path +} + +fn verbose_json_response() -> serde_json::Value { + serde_json::json!({ + "task": "transcribe", + "language": "en", + "duration": 0.1_f32, + "text": "test", + "segments": [{ + "id": 0, + "seek": 0, + "start": 0.0_f32, + "end": 0.1_f32, + "text": "test", + "tokens": [], + "temperature": 0.0_f32, + "avg_logprob": -0.1_f32, + "compression_ratio": 1.0_f32, + "no_speech_prob": 0.0_f32, + }], + // WHY usage block: async-openai 0.36's + // CreateTranscriptionResponseVerboseJson types `usage` as + // non-Option (TranscriptTextUsageDuration). Servers older than + // that schema may omit it, but the typed wrapper requires it. + "usage": { + "type": "duration", + "seconds": 0.1_f32, + } + }) +} + +fn preset_for(server: &MockServer) -> ProviderPreset { + // WHY String::leak: ProviderPreset uses `&'static str` for base_url to + // keep the public preset table allocation-free. Tests need a runtime + // base_url; leaking the small URL string for the duration of the + // process is acceptable in the test binary (process exits in seconds). + ProviderPreset { + name: "test", + base_url: server.uri().leak(), + default_model: "whisper-1", + file_size_limit_bytes: 25_000_000, + auth_scheme: AuthScheme::Bearer, + } +} + +fn build_request( + source: std::path::PathBuf, + progress_calls: &Arc>>, +) -> TranscribeRequest { + let progress_clone = Arc::clone(progress_calls); + let on_progress: Arc = + Arc::new(move |p| progress_clone.lock().unwrap().push(format!("{p:?}"))); + TranscribeRequest { + source, + language_hint: Some("en".to_owned()), + cancel: CancellationToken::new(), + on_progress, + timeout: Some(Duration::from_secs(10)), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn happy_path_returns_segments_and_progress_lifecycle() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(200).set_body_json(verbose_json_response())) + .mount(&server) + .await; + + let preset = preset_for(&server); + let runtime = tokio::runtime::Handle::current(); + let audio: Arc = Arc::new(StubAudioPipeline); + let transcriber = + OpenAICompatibleTranscriber::new(&preset, "test-key".to_owned(), None, runtime, audio) + .unwrap(); + + let progress_calls = Arc::new(std::sync::Mutex::new(Vec::new())); + let req = build_request(small_test_audio(), &progress_calls); + + let result = tokio::task::spawn_blocking(move || transcriber.transcribe(&req)) + .await + .unwrap() + .unwrap(); + + assert_eq!(result.segments.len(), 1); + assert_eq!(result.segments[0].text, "test"); + assert_eq!(result.segments[0].start_ms, 0); + assert_eq!(result.segments[0].end_ms, 100); + assert_eq!(result.language.as_deref(), Some("en")); + assert_eq!(result.duration_ms, 100); + assert_eq!(result.backend.0, "test:whisper-1"); + + // Snapshot the progress vec out from under the mutex so the lock guard + // drops immediately (clippy::significant_drop_tightening). + let progress: Vec = progress_calls.lock().unwrap().clone(); + // Started + Finished, in that order. + assert!( + progress.iter().any(|s| s.contains("Started")), + "missing Started; got {progress:?}" + ); + assert!( + progress.iter().any(|s| s.contains("Finished")), + "missing Finished; got {progress:?}" + ); + let started_idx = progress.iter().position(|s| s.contains("Started")).unwrap(); + let finished_idx = progress + .iter() + .position(|s| s.contains("Finished")) + .unwrap(); + assert!( + started_idx < finished_idx, + "Started must precede Finished; got {progress:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auth_failure_maps_to_typed_auth_error() { + let server = MockServer::start().await; + let body = serde_json::json!({ + "error": { + "message": "Incorrect API key provided", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key", + } + }); + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(401).set_body_json(body)) + .mount(&server) + .await; + + let preset = preset_for(&server); + let runtime = tokio::runtime::Handle::current(); + let audio: Arc = Arc::new(StubAudioPipeline); + let transcriber = + OpenAICompatibleTranscriber::new(&preset, "wrong-key".to_owned(), None, runtime, audio) + .unwrap(); + + let progress_calls = Arc::new(std::sync::Mutex::new(Vec::new())); + let req = build_request(small_test_audio(), &progress_calls); + + let result = tokio::task::spawn_blocking(move || transcriber.transcribe(&req)) + .await + .unwrap(); + + match result { + Err(CoreError::Transcription(TranscriptionError::Auth)) => {} + other => panic!("expected Auth, got {other:?}"), + } +} + +// WHY no end-to-end 5xx test: async-openai 0.36's default +// `ExponentialBackoff` retries 5xx responses indefinitely (~15 min total +// wall-clock before giving up). Constructing a per-test client with a +// no-retry backoff would require leaking a `with_backoff` override into +// the production `OpenAICompatibleTranscriber::new` API surface, which +// is not justified for v1. The `tests/error_mapping.rs` unit tests cover +// the 5xx → BackendUnavailable mapping comprehensively against the typed +// `ApiError` shape — which is exactly what the cloud client surfaces +// after a successful read_response. The end-to-end 4xx (Auth) test above +// already proves the wiremock + adapter wiring is correct. + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deserialization_failure_maps_to_internal_error() { + // Server returns 200 + non-JSON body; async-openai's JSONDeserialize + // path fires (no retry on 2xx malformed bodies). + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/audio/transcriptions")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json at all")) + .mount(&server) + .await; + + let preset = preset_for(&server); + let runtime = tokio::runtime::Handle::current(); + let audio: Arc = Arc::new(StubAudioPipeline); + let transcriber = + OpenAICompatibleTranscriber::new(&preset, "test-key".to_owned(), None, runtime, audio) + .unwrap(); + + let progress_calls = Arc::new(std::sync::Mutex::new(Vec::new())); + let req = build_request(small_test_audio(), &progress_calls); + + let result = tokio::task::spawn_blocking(move || transcriber.transcribe(&req)) + .await + .unwrap(); + + match result { + Err(CoreError::Transcription(TranscriptionError::Internal(msg))) => { + assert!( + msg.contains("deserialize") || msg.contains("not json"), + "got {msg}" + ); + } + other => panic!("expected Internal, got {other:?}"), + } +} diff --git a/deny.toml b/deny.toml index df26d7e..d244c4d 100644 --- a/deny.toml +++ b/deny.toml @@ -86,6 +86,14 @@ ignore = [ { id = "RUSTSEC-2024-0436", reason = "paste: GTK-rs transitive; no direct dep" }, # proc-macro-error — unmaintained; GTK bindings transitive. { id = "RUSTSEC-2024-0370", reason = "proc-macro-error: GTK-rs transitive; no direct dep" }, + # backoff — unmaintained; pulled in transitively via async-openai 0.36's + # default ExponentialBackoff retry policy. Upstream replacement is `backon` + # but async-openai hasn't migrated. Tracked as part of the async-openai + # client polish + see GH #178 (we intend to disable async-openai's retry + # logic for transcription anyway, which removes the runtime dep). + { id = "RUSTSEC-2025-0012", reason = "backoff: async-openai 0.36 transitive; replacement tracked in #178" }, + # instant — unmaintained; transitive via backoff. Same chain as above. + { id = "RUSTSEC-2024-0384", reason = "instant: backoff transitive (via async-openai 0.36); same chain as RUSTSEC-2025-0012" }, ] [sources] diff --git a/docs/routines/adversarial-audit.md b/docs/routines/adversarial-audit.md deleted file mode 100644 index 8ca6add..0000000 --- a/docs/routines/adversarial-audit.md +++ /dev/null @@ -1,499 +0,0 @@ -# Adversarial Audit Routine — Design Doc - -**Status:** design proposal, not yet implemented -**Date:** 2026-04-16 -**Author:** Claude (session with utof) -**Intended reader:** human or AI agent investigating how to turn this into an actual routine / GitHub Action / scheduled job. - ---- - -## TL;DR - -We want a scheduled automation that adversarially audits the `utof/perima` repo after each phase of work, files GitHub issues for any real bugs it finds, and stays honest over time (low false-positive rate, reproducible output, verifiable proof per claim). The hard part is not "tell an LLM to find bugs" — the hard part is preventing the LLM from *inventing* bugs to satisfy the directive. - -This doc specifies **what the routine must do**, **the discipline required to prevent finding-inflation**, **the prompt template**, **two infrastructure options (Claude-only vs Claude+codex cross-model)**, and **the feedback loop to detect drift**. - -A working example of this routine's effective output already exists in this repo's history: the `codex:codex-rescue` pass run on 2026-04-16 against phase 4 (v0.4.0..v0.4.1) produced 1 CRIT + 4 HIGH + 3 MED + 4 LOW findings, all with `file:line` pointers, all verifiable. That output is the target quality bar. See: GH issues #15, #16, #17, #18. - ---- - -## Motivation - -### Why we want adversarial audits - -Per-commit code review (what we already do via `superpowers:code-reviewer` dispatches) catches local correctness issues. It does NOT reliably catch: - -- Cross-commit regressions (a fix in commit A undoes a guarantee established in commit X). -- Silent architectural drift (`FileRepository &mut self` vs `Arc` tension — latent for months before biting). -- Security gaps introduced by innocuous-looking config (Tauri `**` wildcard scope). -- Dead state (columns written but never read, or vice versa). -- Coverage gaps where the obvious broken path has no regression test. - -Adversarial review — one agent specifically tasked to find what's wrong rather than validate what was requested — is a well-known pattern for catching these. We proved it works here: the codex:rescue pass caught 5 serious bugs (including 1 security issue) that all the per-commit reviewers missed. - -### Why LLM-driven adversarial review has a failure mode - -Instructing an LLM to "find issues" is a reward-hacking invitation. The model has strong priors that any non-trivial code has bugs somewhere, so it will invent plausible-sounding findings when none exist. Without discipline, the output degrades into: - -- "This code smells" (no observation can refute it). -- "Could race under extreme load" (no demonstration). -- "Consider refactoring X to Y" (taste, not correctness). -- Findings that repeat from run to run with slight rewording. - -After a few cycles of this the human stops reading the routine's output → routine becomes dead weight → we're worse off than having no routine. - -**The fix is not "prompt nicely." The fix is structural — make the routine physically unable to file a finding without attaching a falsifiable, verifiable proof artifact.** - ---- - -## The three disciplines - -### 1. Falsifiability - -A finding must be expressible as: *"if condition X holds, observable behavior Y occurs."* If no observation could refute it, the finding is prose and must be dropped. - -- **Passes:** "`upsert_metadata` produces duplicate active rows under concurrent calls from two connections." You can construct the concurrent call and observe. -- **Passes:** "Tauri `convertFileSrc` with scope `**` resolves any absolute path." You can convertFileSrc("/etc/passwd") and observe the URL. -- **Fails:** "This code feels over-engineered." Nothing could prove this wrong. -- **Fails:** "Error handling could be better." Neither specific nor falsifiable. - -### 2. Reproducibility - -The same commit audited twice must produce the same finding set (or at least overlap ≥ 80%). This requires: - -- **Canonical finding IDs.** Each finding has a stable hash = `sha256(file_path + line_range + claim_kind_enum)`. Finding IDs that match an existing open or closed issue are skipped — never re-filed. -- **Bounded output.** Max 5 findings per run. If more candidates survive filtering, rank by severity and file top-N; mention the rest in the audit-log for human review. This prevents the "generate 20 findings to look thorough" failure mode. -- **Deterministic scan order.** Alphabetical by file path. Makes cross-run diffs meaningful. - -### 3. Verifiability (the hard one) - -Every finding must attach exactly ONE **mechanically executable** proof artifact: - -**(a) Failing Rust test.** Copy-pastable snippet in `crates/*/tests/*.rs` format that fails against current HEAD. The routine must mentally execute the test against the actual code path and confirm the assertion would fail. - -**(b) Shell reproducer.** Exact command sequence + expected (wrong) output. A human or CI can run it verbatim and observe the bug. - -**(c) Authoritative citation.** URL with line anchor to the library's source or docs (e.g. `github.com/image-rs/image/blob/v0.25.0/src/codecs/webp.rs#L42`) and a quoted sentence of the relevant claim. - -**(d) Regression SHA.** `git log -S ` output identifying the commit that introduced the bug, plus the diff hunk demonstrating the behavior flip. - -Prose-only claims are **forbidden**. No exceptions. - -This discipline biases findings toward mechanical bugs (races, SQL parse errors, CRDT schema drift, security scopes, dead state) and away from design smells (bad abstractions, ergonomics critique). That's the correct bias for this routine — design smells need a human. - ---- - -## The hardened prompt - -``` -Role: Adversarial auditor for utof/perima. HARD CONTRACT: no finding -without executable proof. Hallucinated findings waste engineering -time and erode trust in this routine. - -## Discipline — read before generating - -1. FALSIFIABILITY. Every finding MUST name the observation that would - prove it wrong. If you can't name one, drop the finding. - -2. EXECUTABLE PROOF. Every finding MUST attach ONE of: - (a) A Rust test (crates/*/tests/*.rs format) that copies verbatim - into the repo and FAILS on the current HEAD. Mentally execute - it: step through the code path, confirm the assertion will fail. - If uncertain, sharpen or drop. - (b) A shell reproducer: exact commands + expected (wrong) output. - Must be runnable against the actual current code. - (c) A library-behavior citation: URL with line anchor + quoted - sentence. - (d) A regression introduced by a specific commit: git log -S output - + diff hunks. - - Prose-only claims are FORBIDDEN. - -3. CONFIDENCE + STEELMAN. For each candidate finding: - - Declare confidence: HIGH (proof directly demonstrates bug), - MEDIUM (likely, edge cases), LOW (might be wrong). - - Write ONE sentence steelmanning AGAINST the finding. - - If the steelman is compelling (>20% the code is right), - DROP the finding. Note in audit-log. - - Only HIGH + MEDIUM get filed. LOW goes to the audit-log. - -4. CANONICAL FINDING ID. Compute: - finding_id = sha256( - file_path_canonical + ":" + line_range + ":" + claim_kind - ) - where claim_kind ∈ {race, sql_parse, unhandled_error, - security_scope, crdt_gap, dead_state, test_gap, api_misuse, - memory_leak, correctness_bug}. - Before filing: gh issue list --search "finding_id:". If - found (open OR closed), DO NOT RE-FILE. Note as "skipped (dup of - #N)" in the audit-log. - -5. OUTPUT BUDGET. Max 5 findings per run. If more candidates survive - filters, file top 5 by severity; list the rest in the audit-log - for human review. - -6. READ-ONLY. No code modifications. Read + analyze only. - -## Input - -- Git range: ..HEAD (or last 14 days, whichever is larger). -- Repo: utof/perima. -- Files in scope: every .rs, .sql, .ts, .tsx touched in the diff. -- Constraint: CLAUDE.md at repo root — obey schema + CRDT rules when - evaluating findings against it. - -## Output per finding (markdown issue body template) - -**finding_id:** `` -**severity:** CRIT | HIGH | MED -**confidence:** HIGH | MEDIUM -**file:line:sha:** `crates/db/src/foo.rs:123 (commit abc123)` -**claim_kind:** `race` - -## Claim - - -## Falsifier - - -## Proof artifact - - -## Steelman - - -## Why I filed anyway - - -## Why this matters - - ---- - -Issue labels: `adversarial-audit` + one of `bug` / `security` / -`enhancement`. -Title: `[sev/conf] area: ` (e.g. `[HIGH/HIGH] db: upsert_metadata -clobbers thumbnail state`). - -## Audit-log comment (every run — even empty ones) - -gh issue comment --body <.. -- Commits scanned: N -- Findings filed: K (#a, #b, ...) -- Findings skipped (duplicate of existing issue): M — [links] -- Findings dropped at steelman: J — [one-line reasons] -EOF -``` - -Why the audit-log comment matters: **transparency about what the -routine considered and rejected.** If a human spots a bug manually -that the routine dropped at steelman, that's a prompt-tuning signal. - ---- - -## Infrastructure options - -### Option A — Claude routine, Claude-only - -**What:** A scheduled Claude Code routine (per the Nov 2025 routines -feature) runs the hardened prompt weekly. - -**Setup:** -1. Log in to Claude Code's routine UI. -2. Create a new scheduled routine with cadence `0 22 * * 0` (Sundays - 22:00 UTC). -3. Paste the hardened prompt above. -4. Connect the `utof/perima` GitHub repo. -5. Authorize `gh` connector (so the routine can `gh issue create`). - -**Pros:** -- No extra infra beyond the Claude Code routine UI. -- No secondary API key. -- Prompt iterations are a single-file edit. -- Uses the same model family we've been using — consistent with the - rest of the dev loop. - -**Cons:** -- Same-model-family reviewing its own recent work is less independent - than cross-model. Claude's blind spots stay blind. -- Limited to whatever the routine runtime supports (tool access, - file read bandwidth, output length). - -**Recommended for:** MVP. Run it for a month, measure false-positive -rate, decide whether to upgrade to Option B. - -### Option B — Claude routine + codex via GitHub Action (cross-model) - -**What:** A GitHub Action runs `codex exec` with the hardened prompt -an hour before the Claude routine; emits findings as structured JSON; -posts to the audit-log issue as a comment. The Claude routine reads -that comment, applies the dedup + steelman filter, and files issues. - -**Architecture:** - -``` -┌─────────────────────┐ ┌─────────────────────────┐ -│ GH Action (scheduled)│ │ Claude routine │ -│ Sun 21:00 UTC │ │ Sun 22:00 UTC │ -│ │ │ │ -│ - checkout repo │ │ - read audit-log comment│ -│ - install codex CLI │ │ - apply filters │ -│ - run codex exec │ artifact │ - file issues │ -│ with hardened │──────────▶│ - write audit-log │ -│ prompt │ (JSON) │ │ -│ - gh issue comment │ │ │ -│ AUDIT_LOG with │ │ │ -│ findings.json │ │ │ -└─────────────────────┘ └─────────────────────────┘ -``` - -**Setup sketch:** - -`.github/workflows/adversarial-audit.yml`: - -```yaml -name: adversarial-audit (codex) - -on: - schedule: [{ cron: "0 21 * * 0" }] # Sun 21:00 UTC - workflow_dispatch: - -jobs: - codex-audit: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } - - - name: Install codex CLI - run: npm install -g @openai/codex # or whichever package name applies at the time - - - name: Run codex adversarial audit - run: | - codex exec --cd . \ - --output findings.json \ - --prompt-file .github/adversarial-audit-prompt.md - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - - - name: Post findings to audit-log - run: | - gh issue comment ${{ vars.AUDIT_LOG_ISSUE_NUMBER }} \ - --body-file findings.json - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -`.github/adversarial-audit-prompt.md` contains the hardened prompt -from above. - -The Claude routine then runs at 22:00 UTC, pulls the fresh comment, -processes. - -**Pros:** -- Genuinely cross-model (GPT-5-class reviewing Claude-generated code - and vice versa). -- Catches blind-spot classes that a same-model review misses. -- Both models apply the SAME discipline (proof-required contract). - Any finding surviving both passes is extremely likely to be real. - -**Cons:** -- Two systems, two API keys, two failure modes. -- OpenAI billing per run. -- More setup + more moving parts to maintain. -- Latency: 1-hour gap between the two runs for the handoff. - -**Recommended for:** mature projects where false-negative cost is -high. For `perima` in its pre-v1 state, Option A is enough. - -### Option C — pure GitHub Action (no routine at all) - -If routines aren't available / too expensive / undesired: a single -scheduled GitHub Action that invokes an LLM CLI (codex or Claude API) -with the hardened prompt and files issues via `gh`. - -**Pros:** fully self-hosted, no Claude Code routine dependency. -**Cons:** you lose Claude Code's persistent context across runs (each -run starts fresh with no memory of previously-dismissed findings -beyond what the `finding_id` dedup catches). - ---- - -## Feedback loop — detecting drift over time - -A single adversarial routine left unsupervised drifts toward one of -two failure modes: - -- **Over-eager:** too many false positives. Humans stop reading. -- **Over-conservative:** too many false negatives. Bugs slip through. - -The audit-log issue is the monitoring surface. Three labels track drift: - -- **`adversarial-false-positive`** — applied manually when a - routine-filed issue is closed as wrong / not-a-bug. Tracks over- - eagerness. -- **`adversarial-missed`** — applied manually when a bug is found the - hard way (production failure, manual code review) and we realize the - adversarial audit should have caught it. Tracks over-conservatism. -- **`adversarial-audit`** — applied to every finding; the universe. - -### Monthly drift check (15 min, manual) - -1. Count: `gh issue list --label adversarial-false-positive --state closed --search "closed:>=YYYY-MM-DD"`. -2. Divide by total closed with `adversarial-audit` in the same window. -3. If false-positive ratio > 20%: prompt is too eager. Tune: raise - the steelman bar (`>40% the code is right → drop`). -4. If false-negative (missed) count > 1 per month: prompt is too - conservative. Tune: lower the steelman bar (`>10%` → drop). - -### Monthly honesty check (also 15 min) - -Pick 3 random adversarial-audit issues filed in the month. For each: - -1. Copy the proof artifact. -2. Paste into the repo / run the command. -3. Confirm: does the test fail? Does the repro output match? -4. If 3/3 check out → routine is honest. Continue. -5. If ≤ 2/3 check out → the routine is claiming proofs that don't - actually reproduce. Add the failing example as a NEVER-FILE exemplar - in the prompt and re-deploy. - ---- - -## The tradeoff — what this routine does NOT do - -**Design smells:** abstractions, API ergonomics, module cohesion. -Falsifiability rules them out. They need a human reviewer. - -**Performance regressions:** without instrumented benchmarks, claims -are pretend. Consider a separate benchmarking routine if performance -starts to matter. - -**Cross-component integration behavior:** the routine reads diffs, -not the whole codebase. A bug that requires understanding 4 modules -at once might slip. Mitigate by periodically running a larger -whole-codebase audit (like the phase-4 codex:rescue we did). - ---- - -## Implementation roadmap - -For a human or AI to actually build this: - -### Phase 1 — scaffolding (2-4 hours) - -1. Create the audit-log tracking issue: - ```bash - gh issue create --repo utof/perima \ - --title "Audit-log: adversarial-audit routine runs" \ - --label adversarial-audit \ - --body "Running log of adversarial-audit findings, skips, and drops. See docs/routines/adversarial-audit.md." - ``` - Note the issue number → `AUDIT_LOG_ISSUE_NUMBER` env var below. - -2. Save the hardened prompt as `.github/adversarial-audit-prompt.md` - (COMMITTED — this one .md file is allowed despite repo's gitignore - rule, since it's required by the CI workflow. Add `!.github/adversarial-audit-prompt.md` to `.gitignore`). - -3. Pick Option A (Claude routine) or Option B (Action+routine hybrid) - and configure. - -### Phase 2 — first run + calibration (1 week) - -4. Run the routine manually once via `workflow_dispatch` (Option B) or - "Run now" button (Option A). -5. Read every filed issue. For each, verify the proof artifact. -6. Close any that are wrong, labeling `adversarial-false-positive`. -7. File any bugs the routine should have caught but didn't, labeling - `adversarial-missed`. -8. Tune the prompt: if false-positive rate > 20%, raise steelman bar; - if false-negative rate > 1 / week, lower it. - -### Phase 3 — steady state (monthly review) - -9. Once false-positive rate is consistently < 20% and false-negative - rate < 1/month, declare the routine "calibrated." -10. Monthly 15-min drift check per the protocol above. -11. When phase structure changes (e.g., starting phase 5+), refresh - the prompt's "files in scope" section. - -### Phase 4 (optional) — upgrade to Option B cross-model - -12. If Option A's findings plateau (same classes of bugs caught, blind - spots to other classes), add the codex Action. -13. Compare: what does codex find that Claude misses? What does - Claude find that codex misses? -14. Keep both running; they're complementary. - ---- - -## Proof-of-concept already in the repo - -The `codex:codex-rescue` adversarial pass run on 2026-04-16 against -phase 4 (commit range `v0.3.2..v0.4.1`) produced findings matching -this spec's quality bar. See: - -- GH issue #15 (CRIT + HIGH v0.4.2 hotfix meta) -- GH issue #16 (MED queue cancellation + HIGH TOCTOU) -- GH issue #17 (MED test coverage gaps) -- GH issue #18 (LOW architectural cleanups) - -Every finding in those issues had a `file:line` pointer. Most had -implicit proof artifacts (code snippets quoted, behavior descriptions -matched to the diff). The routine's job is to formalize that quality -level and demand the proof artifacts explicitly — so even when a -run is sloppy, the structure forces accountability. - ---- - -## Open questions for the implementer - -1. **Claude routine vs Anthropic API + GitHub Action?** The routine - feature is Claude Code-specific. If the implementer doesn't have - Claude Code access, they could run the hardened prompt via a - scheduled Action that calls the Anthropic API directly. Same - prompt, different harness. - -2. **Finding ID canonicalization.** `file_path_canonical` — normalize - to forward slashes, strip repo prefix. `line_range_canonical` — - use `start-end` of the primary defect location, not the full - surrounding context. `claim_kind_canonical` — the small enum is - non-negotiable; bigger enums create more ID collisions. - -3. **What happens if the routine crashes mid-run?** It should be - idempotent — re-running with the same commit range produces the - same findings (by ID). Partial filings are OK because dupes are - skipped. - -4. **Permission model.** The routine needs `issues:write` + `contents:read`. - The GH Action route uses `GITHUB_TOKEN` with scoped permissions. - A Claude routine needs equivalent — verify Claude Code's connector - model supports issue creation without broader repo access. - -5. **Chain to autonomous-fix routine?** A separate routine could pick - up `[HIGH/HIGH] adversarial-audit` labeled issues and attempt a - draft PR. The human stays in the loop for judgment calls. Not in - scope for this doc — flagged as natural follow-up. - ---- - -## Summary for a fresh reader - -- Adversarial audits catch real bugs that per-commit review misses. -- LLM-driven adversarial audits hallucinate if you don't enforce - falsifiability, reproducibility, and verifiability. -- The hardened prompt above encodes those disciplines structurally: - no proof → no filing. -- Option A (Claude routine) is the MVP. Option B (Claude + codex) is - the upgrade path when blind spots appear. -- Monthly 15-min feedback loop (false-positive + false-negative - tracking) keeps the routine honest. -- Bias toward mechanical bugs; leave design review to humans. - -The codex:rescue pass on phase 4 is the working example this routine -systematizes. diff --git a/docs/superpowers/plans/2026-04-15-phase-0-scaffold.md b/docs/superpowers/plans/2026-04-15-phase-0-scaffold.md deleted file mode 100644 index b096f10..0000000 --- a/docs/superpowers/plans/2026-04-15-phase-0-scaffold.md +++ /dev/null @@ -1,968 +0,0 @@ -# Phase 0 — Scaffold & Gates Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stand up the Rust workspace, lints, justfile, pre-commit hook, and GitHub Actions pipeline so phase 1's first line of product code ships under the full quality bar. - -**Architecture:** Virtual Cargo workspace with five empty crates (`core`, `db`, `fs`, `hash`, `cli`). Workspace-level lints enforce doc coverage, clippy pedantic + complexity, rustdoc intra-doc-links. `justfile` composes `fmt-check + clippy + test + doctest + docs-coverage` into `just ci`. Pre-commit git hook runs `just ci`. GitHub Actions mirrors the hook across Linux/macOS/Windows; nightly workflow runs kani (step-level `continue-on-error`, never blocks main). - -**Tech Stack:** Rust 1.85+, edition 2024, resolver 3; rusqlite 0.38 + refinery 0.9 (pinned pair); blake3, walkdir, notify, tauri 2 (phase-2 pre-pin); `just`, `actionlint`, native git hooks, GitHub Actions with `Swatinem/rust-cache@v2`, `dtolnay/rust-toolchain@stable`, `taiki-e/install-action@just`. - -**Spec:** `docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md` - -**Execution rule (from CLAUDE.md):** All work on `main`. Per-commit order: execute → `just ci` green → reviewer subagent approves → commit. Never commit unreviewed work. No branches, no worktrees, no `--force` push. - ---- - -## File Structure - -**Committed to git:** - -``` -perima/ -├── Cargo.toml # Task 2 — virtual workspace manifest -├── Cargo.lock # generated first cargo build -├── rustfmt.toml # Task 3 -├── .gitattributes # Task 4 -├── .gitignore # Task 5 (modify) -├── justfile # Task 6 -├── crates/ -│ ├── core/{Cargo.toml,src/lib.rs} # Task 2 -│ ├── db/{Cargo.toml,src/lib.rs} # Task 2 -│ ├── fs/{Cargo.toml,src/lib.rs} # Task 2 -│ ├── hash/{Cargo.toml,src/lib.rs} # Task 2 -│ └── cli/{Cargo.toml,src/main.rs} # Task 2 -├── scripts/ -│ ├── pre-commit # Task 7 -│ └── test-precommit-hook.sh # Task 8 -└── .github/workflows/ - ├── ci.yml # Task 9 - └── kani.yml # Task 10 -``` - -**Created but gitignored (`**/*.md` + other):** -- `DECISIONS.md` — ADR log (Task 11) -- `README.md` — local scratchpad (Task 12) - -**Split rationale:** each crate owns one concern (`core` = domain + trait ports only, adapters and CLI wire them). Scripts under `scripts/`, workflows under `.github/workflows/` — standard layout. - ---- - -## Task 0: Environment preflight - -No files changed. Purely a gate — abort if the machine isn't ready. - -- [ ] **Step 1: Rust toolchain ≥ 1.85** - -Run: `rustc --version | awk '{print $2}'` -Expected: a version string ≥ `1.85.0`. -If missing: `rustup install stable && rustup default stable`. - -- [ ] **Step 2: Cargo components present** - -Run: `rustup component list --installed` -Expected: includes `clippy` and `rustfmt`. -If missing: `rustup component add clippy rustfmt`. - -- [ ] **Step 3: `just` installed** - -Run: `just --version` -Expected: any `just 1.x.y` line. -If missing: `cargo install just --locked` OR `brew install just` OR `apt install just`. - -- [ ] **Step 4: `gh` CLI authenticated** - -Run: `gh auth status` -Expected: "Logged in to github.com as utof" (or similar). -If not authenticated: `gh auth login` (interactive — must be done by user; pause plan until resolved). - -- [ ] **Step 5: Remote state check — avoid non-fast-forward push later** - -Run: `git ls-remote origin` -Expected: **empty output** (remote `main` does not yet exist) OR remote only contains refs we can safely fast-forward into. - -If remote has a commit we do not have locally (e.g., auto-generated README from GitHub web UI), **STOP**. Do one of the following: -1. `git fetch origin && git reset --hard origin/main` and restart phase 0 against the existing base (preferred — preserves remote history). -2. Delete the remote content via GitHub UI and retry (destructive; only if remote content is known-empty auto-init). - -Do NOT use `git push --force`; CLAUDE.md forbids it. - -- [ ] **Step 6: No reviewer needed — this is a readiness gate, not a deliverable.** - ---- - -## Task 1: Workspace `Cargo.toml` + first empty crate (`core`) - -**Files:** -- Create: `Cargo.toml` -- Create: `crates/core/Cargo.toml` -- Create: `crates/core/src/lib.rs` - -Fused with Task 2's remaining crates to avoid an intermediate "expected-fail" state. Task 1 stands alone as the minimum that builds. - -- [ ] **Step 1: Create `Cargo.toml`** - -Content: - -```toml -[workspace] -members = ["crates/*"] -resolver = "3" - -[workspace.package] -edition = "2024" -rust-version = "1.85" -license = "MIT OR Apache-2.0" -repository = "https://github.com/utof/perima" - -[workspace.lints.rust] -missing_docs = "deny" -unsafe_code = "deny" - -[workspace.lints.rustdoc] -broken_intra_doc_links = "deny" -private_intra_doc_links = "deny" - -[workspace.lints.clippy] -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -cognitive_complexity = "warn" -too_many_lines = "warn" -excessive_nesting = "warn" -module_name_repetitions = "allow" -missing_errors_doc = "warn" - -[workspace.dependencies] -# Pinned against crates.io as of 2026-04-15. rusqlite held at 0.38 -# to pair with refinery 0.9 (refinery-core caps rusqlite at 0.38). -anyhow = "1" -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tokio = { version = "1", features = ["full"] } -uuid = { version = "1", features = ["v4", "v7", "serde"] } -blake3 = "1" -rusqlite = { version = "0.38", features = ["bundled"] } -refinery = { version = "0.9", features = ["rusqlite"] } -walkdir = "2" -notify = "8.2" -notify-debouncer-full = "0.7" -sysinfo = "0.38" -directories = "6" -unicode-normalization = "0.1" -path-slash = "0.2" -dunce = "1" -file-id = "0.2" -tauri = { version = "2", features = [] } -tauri-specta = "=2.0.0-rc.24" - -[profile.release] -lto = "thin" -codegen-units = 1 -``` - -- [ ] **Step 2: Create `crates/core/Cargo.toml`** - -Content: - -```toml -[package] -name = "perima-core" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] - -[lints] -workspace = true -``` - -- [ ] **Step 3: Create `crates/core/src/lib.rs`** - -Content: - -```rust -//! Domain types and trait ports for perima. -//! -//! This crate has zero framework dependencies. Every other crate in -//! the workspace either defines types consumed here or adapts this -//! crate's traits to a concrete backend. - -/// Marker placeholder. Replaced with real domain types in phase 1. -pub const CRATE_NAME: &str = "perima-core"; -``` - -- [ ] **Step 4: Verify the minimum workspace builds** - -Run: `cargo build -p perima-core` -Expected: success. - ---- - -## Task 2: Remaining empty crates - -**Files:** -- Create: `crates/db/{Cargo.toml,src/lib.rs}` -- Create: `crates/fs/{Cargo.toml,src/lib.rs}` -- Create: `crates/hash/{Cargo.toml,src/lib.rs}` -- Create: `crates/cli/{Cargo.toml,src/main.rs}` - -- [ ] **Step 1: Create `crates/db/Cargo.toml`** - -```toml -[package] -name = "perima-db" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] - -[lints] -workspace = true -``` - -- [ ] **Step 2: Create `crates/db/src/lib.rs`** - -```rust -//! SQLite adapter for perima (phase 1 brings the real implementation). - -/// Marker placeholder. -pub const CRATE_NAME: &str = "perima-db"; -``` - -- [ ] **Step 3: Create `crates/fs/Cargo.toml`** - -```toml -[package] -name = "perima-fs" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] - -[lints] -workspace = true -``` - -- [ ] **Step 4: Create `crates/fs/src/lib.rs`** - -```rust -//! Filesystem scanning, watching, and path normalization for perima. - -/// Marker placeholder. -pub const CRATE_NAME: &str = "perima-fs"; -``` - -- [ ] **Step 5: Create `crates/hash/Cargo.toml`** - -```toml -[package] -name = "perima-hash" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] - -[lints] -workspace = true -``` - -- [ ] **Step 6: Create `crates/hash/src/lib.rs`** - -```rust -//! BLAKE3-based content-hashing adapter for perima. - -/// Marker placeholder. -pub const CRATE_NAME: &str = "perima-hash"; -``` - -- [ ] **Step 7: Create `crates/cli/Cargo.toml`** - -```toml -[package] -name = "perima" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] - -[lints] -workspace = true -``` - -(`src/main.rs` is auto-discovered when `name = "perima"`; no `[[bin]]` block needed.) - -- [ ] **Step 8: Create `crates/cli/src/main.rs`** - -```rust -//! `perima` command-line entry point (phase 1 adds real subcommands). - -/// Entry point — prints a placeholder until phase 1 wires real commands. -fn main() { - println!("perima 0.1.0 (phase 0 scaffold)"); -} -``` - -- [ ] **Step 9: Verify workspace build** - -Run: `cargo build --workspace` -Expected: success. `Cargo.lock` is created. - -Note: `[workspace.dependencies]` does NOT pull those deps into the lockfile unless a crate's own `[dependencies]` lists them. The empty crates don't — so the lockfile stays minimal until phase 1. - -- [ ] **Step 10: Verify clippy is clean** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: exit 0, zero warnings. - -- [ ] **Step 11: Verify doc coverage** - -Run: `cargo doc --workspace --no-deps` -Expected: exit 0. (Enforcement comes from `[workspace.lints.rust] missing_docs = "deny"` plus `[workspace.lints.rustdoc] broken_intra_doc_links = "deny"`.) - ---- - -## Task 3: `rustfmt.toml` - -**Files:** -- Create: `rustfmt.toml` - -- [ ] **Step 1: Create the config** - -```toml -edition = "2024" -max_width = 100 -use_field_init_shorthand = true -use_try_shorthand = true -imports_granularity = "Crate" -group_imports = "StdExternalCrate" -``` - -- [ ] **Step 2: Verify fmt passes** - -Run: `cargo fmt --all -- --check` -Expected: exit 0. - -Note: `imports_granularity` / `group_imports` are nightly-only; inert on stable. - ---- - -## Task 4: `.gitattributes` - -**Files:** -- Create: `.gitattributes` - -- [ ] **Step 1: Create the file** - -``` -* text=auto eol=lf -*.sh text eol=lf -*.bat text eol=crlf -``` - -- [ ] **Step 2: Renormalize the working tree** - -Run: `git add --renormalize .` -Expected: no error. - ---- - -## Task 5: Update `.gitignore` - -**Files:** -- Modify: `.gitignore` - -- [ ] **Step 1: Replace content** - -Final `.gitignore`: - -``` -**/*.md -target/ -.DS_Store -*.swp -``` - -- [ ] **Step 2: Verify no `.md` is tracked** - -Run: `git ls-files '*.md'` -Expected: empty. - ---- - -## Task 6: `justfile` + first commit - -**Files:** -- Create: `justfile` - -- [ ] **Step 1: Create the justfile** - -```just -set shell := ["bash", "-eo", "pipefail", "-c"] - -default: ci - -test: - cargo test --workspace --all-targets - -clippy: - cargo clippy --workspace --all-targets -- -D warnings - -doctest: - cargo test --workspace --doc - -mdbook-test: - cargo test --workspace --doc -- --show-output - -docs-coverage: - cargo doc --workspace --no-deps - -fmt-check: - cargo fmt --all -- --check - -fmt: - cargo fmt --all - -ci: fmt-check clippy test doctest docs-coverage - -verify: - @if command -v cargo-kani >/dev/null 2>&1; then \ - cargo kani --workspace; \ - else \ - echo "cargo-kani not installed; skipping"; \ - fi - -install-hooks: - cp scripts/pre-commit .git/hooks/pre-commit - chmod +x .git/hooks/pre-commit - -test-hook: - bash scripts/test-precommit-hook.sh -``` - -Note: `docs-coverage` is now a plain `cargo doc --workspace --no-deps`. The hard-failure gate comes from `[workspace.lints.rust] missing_docs = "deny"` (catches missing doc comments) plus `[workspace.lints.rustdoc] broken_intra_doc_links = "deny"` (catches dangling `[link]` references). No string parsing, no `RUSTDOCFLAGS` (the `rustdoc::missing_docs` lint name does not exist — `missing_docs` is a rustc lint, not a rustdoc lint). - -- [ ] **Step 2: Run `just ci`** - -Run: `just ci` -Expected: all five targets pass; exit 0. - -- [ ] **Step 3: Dispatch reviewer subagent (checkpoint #1 — Tasks 0–6)** - -Reviewer checklist: -- [ ] Workspace manifest parses and resolves. -- [ ] All five crates build with empty `[dependencies]`. -- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean. -- [ ] `cargo fmt --all -- --check` is clean. -- [ ] `docs-coverage` emits zero missing-doc warnings. -- [ ] `just ci` exits 0. -- [ ] `.gitignore` excludes `**/*.md`, `target/`, `.DS_Store`, `*.swp`. -- [ ] `.gitattributes` pins LF. -- [ ] `rustfmt.toml` is edition 2024. -- [ ] No `.md` file in `git ls-files`. - -Must return APPROVED before step 4. - -- [ ] **Step 4: Commit (only after APPROVED)** - -```bash -git add Cargo.toml Cargo.lock rustfmt.toml .gitattributes .gitignore justfile crates/ -git commit -m "feat(phase-0): workspace scaffold, lints, justfile - -Virtual Cargo workspace with five empty crates (core/db/fs/hash/cli). -Workspace-level doc + clippy + rustdoc gates. justfile composes -fmt-check + clippy + test + doctest + docs-coverage into 'just ci'. - -Refs: docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md" -``` - -Run: `git status` -Expected: clean tree. - ---- - -## Task 7: Pre-commit hook - -**Files:** -- Create: `scripts/pre-commit` - -- [ ] **Step 1: Create the script** - -```bash -#!/usr/bin/env bash -set -euo pipefail -# Git hooks run in non-interactive shells that may not source ~/.bashrc, -# so cargo-installed binaries may not be on PATH. Prepend the standard -# location explicitly to make this hook portable across shells / git GUIs. -export PATH="$HOME/.cargo/bin:$PATH" -just ci -``` - -- [ ] **Step 2: Make executable** - -Run: `chmod +x scripts/pre-commit` - -- [ ] **Step 3: Install** - -Run: `just install-hooks` -Expected: `.git/hooks/pre-commit` exists and is executable. - -- [ ] **Step 4: Verify** - -Run: `test -x .git/hooks/pre-commit && echo ok` -Expected: `ok`. - ---- - -## Task 8: Scripted hook test + commit - -**Files:** -- Create: `scripts/test-precommit-hook.sh` - -- [ ] **Step 1: Create the script** - -```bash -#!/usr/bin/env bash -set -euo pipefail -# Same rationale as scripts/pre-commit: ensure cargo-installed binaries -# (notably `just`) are reachable when this script runs from a nested or -# non-interactive shell. -export PATH="$HOME/.cargo/bin:$PATH" -cd "$(git rev-parse --show-toplevel)" - -target="crates/core/src/lib.rs" -if [[ ! -f "$target" ]]; then - echo "FAIL: expected $target to exist (phase 0 scaffolding incomplete)" >&2 - exit 2 -fi - -just ci >/dev/null -echo "baseline: just ci green" - -backup="$(mktemp)" -cp "$target" "$backup" -trap 'cp "$backup" "$target"; rm -f "$backup"' EXIT INT TERM HUP -printf '\npub const __HOOK_TEST: i32 =\t0 ; \n' >> "$target" - -set +e -just ci >/dev/null 2>&1 -rc=$? -set -e -if [[ $rc -eq 0 ]]; then - echo "FAIL: just ci passed with a planted fmt violation" >&2 - exit 1 -fi -echo "OK: just ci blocked a commit-equivalent with planted violation (rc=$rc)" -``` - -- [ ] **Step 2: Executable bit** - -Run: `chmod +x scripts/test-precommit-hook.sh` - -- [ ] **Step 3: Run the test** - -Run: `just test-hook` -Expected: -``` -baseline: just ci green -OK: just ci blocked a commit-equivalent with planted violation (rc=...) -``` - -- [ ] **Step 4: Confirm no residue** - -Run: `git status --porcelain crates/core/src/lib.rs` -Expected: empty. - -- [ ] **Step 5: Reviewer subagent (checkpoint #2 — Tasks 7–8)** - -Reviewer checklist: -- [ ] `scripts/pre-commit` contains `just ci` and is executable. -- [ ] `.git/hooks/pre-commit` installed via `just install-hooks`. -- [ ] `scripts/test-precommit-hook.sh` backs up the target, plants into a tracked file, restores via trap on EXIT/INT/TERM/HUP. -- [ ] `just test-hook` passes. -- [ ] After `just test-hook`, `git status` shows no changes to `crates/core/src/lib.rs`. - -Must return APPROVED before step 6. - -- [ ] **Step 6: Commit** - -```bash -git add scripts/pre-commit scripts/test-precommit-hook.sh -git commit -m "feat(phase-0): pre-commit hook + scripted verification - -Native git pre-commit hook runs 'just ci'. test-precommit-hook.sh -plants a fmt violation against crates/core/src/lib.rs, asserts -'just ci' fails, then restores via trap (EXIT/INT/TERM/HUP). - -Refs: docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md" -``` - ---- - -## Task 9: GitHub Actions CI - -**Files:** -- Create: `.github/workflows/ci.yml` - -- [ ] **Step 1: Create the workflow** - -```yaml -name: ci - -on: - push: - branches: [main] - pull_request: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: just ci (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - uses: Swatinem/rust-cache@v2 - - - uses: taiki-e/install-action@just - - - name: Run just ci - shell: bash - run: just ci -``` - -Note: `taiki-e/install-action@just` fetches a prebuilt `just` binary (fast, no compile). `shell: bash` makes the `just` step identical on Windows (Git Bash is preinstalled on `windows-latest`). - -- [ ] **Step 2: Install `actionlint` if missing** - -`actionlint` is a Go binary; it is not on crates.io. Use the official -downloader to install into `~/.local/bin` (no sudo, cross-platform). - -Run: -```bash -if ! command -v actionlint >/dev/null; then - mkdir -p "$HOME/.local/bin" - curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash \ - | bash -s -- latest "$HOME/.local/bin" -fi -export PATH="$HOME/.local/bin:$PATH" -command -v actionlint -``` -Expected: prints a path ending in `actionlint` (e.g., `$HOME/.local/bin/actionlint`). - -Alternative per-platform: `brew install actionlint` (macOS) / `apt install actionlint` (Debian/Ubuntu, if packaged) / `go install github.com/rhysd/actionlint/cmd/actionlint@latest` (if Go is available). - -- [ ] **Step 3: Validate the workflow syntax** - -Use the explicit path in case `$PATH` did not persist from Step 2 -(each task step may run in a fresh shell in autonomous execution): - -Run: `"$HOME/.local/bin/actionlint" .github/workflows/ci.yml || actionlint .github/workflows/ci.yml` -Expected: empty output (no errors). - ---- - -## Task 10: GitHub Actions kani - -**Files:** -- Create: `.github/workflows/kani.yml` - -- [ ] **Step 1: Create the workflow** - -```yaml -name: kani - -on: - schedule: - - cron: "0 3 * * *" - workflow_dispatch: - -jobs: - kani: - name: cargo kani - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Install kani - run: cargo install --locked kani-verifier && cargo kani setup - - # Step-level continue-on-error so job result reflects reality - # but the workflow run never fails. Phase 1 adds the first - # actual proof; until then this is a wiring check. - - name: Run kani proofs - continue-on-error: true - run: cargo kani --workspace -``` - -- [ ] **Step 2: Validate** - -Run: `"$HOME/.local/bin/actionlint" .github/workflows/kani.yml || actionlint .github/workflows/kani.yml` -Expected: empty output. - -- [ ] **Step 3: Reviewer (checkpoint #3 — Tasks 9–10)** - -Reviewer checklist: -- [ ] `ci.yml`: 3-OS matrix, uses `taiki-e/install-action@just`, runs `just ci`, `fail-fast: false`. -- [ ] `kani.yml`: schedule + workflow_dispatch, `continue-on-error` is at step level only (job status is honest). -- [ ] Both files pass `actionlint`. -- [ ] No secrets, no self-hosted runners introduced. - -- [ ] **Step 4: Commit** - -```bash -git add .github/workflows/ci.yml .github/workflows/kani.yml -git commit -m "feat(phase-0): GitHub Actions CI + nightly kani - -ci.yml: 3-OS matrix (Linux/macOS/Windows), just ci via -taiki-e/install-action for fast just install. -kani.yml: nightly schedule + workflow_dispatch; step-level -continue-on-error so workflow never blocks main. - -Refs: docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md" -``` - ---- - -## Task 11: Seed `DECISIONS.md` (local, gitignored) - -**Files:** -- Create: `DECISIONS.md` (never committed) - -- [ ] **Step 1: Create the file** - -```markdown -# perima — architectural decisions log - -Local-only (gitignored). ADR-style. Append-only. - ---- - -## 2026-04-15: Rust edition 2024, resolver 3, rust-version 1.85 - -**Context:** Phase 0 workspace setup. -**Decision:** Edition 2024 + resolver 3, pin `rust-version = "1.85"`. -**Alternatives:** Edition 2021 (longer support), resolver 2 (older). -**Consequences:** Requires stable Rust ≥ 1.85; gains edition 2024 improvements. - -## 2026-04-15: No `crates/xtask` yet - -**Context:** Research doc suggests `xtask`. -**Decision:** Defer; `justfile` suffices. -**Alternatives:** Create xtask upfront. -**Consequences:** Revisit when a shell recipe becomes painful. - -## 2026-04-15: Native git pre-commit hook (no Python `pre-commit` framework) - -**Context:** Commit-time gate. -**Decision:** Bash script at `scripts/pre-commit`, installed via `just install-hooks`. -**Alternatives:** pre-commit Python framework. -**Consequences:** Zero extra language deps; contributors run `just install-hooks` once. - -## 2026-04-15: Pre-commit hook does NOT enforce reviewer-attestation - -**Context:** CLAUDE.md requires reviewer approval before commit. -**Decision:** Hook runs `just ci` only; reviewer-attestation is a process rule enforced by subagent dispatch. -**Alternatives:** Hook checks for `.review-token` artifact. -**Consequences:** Process discipline required; future hook enhancement may close this loop. - -## 2026-04-15: GitHub Actions for CI - -**Context:** Repo hosted on GitHub. -**Decision:** Actions with 3-OS matrix; nightly kani workflow. -**Alternatives:** Self-hosted runners. -**Consequences:** Standard, zero-config for contributors. - -## 2026-04-15: `rusqlite = "0.38"` pinned to pair with `refinery = "0.9"` - -**Context:** rusqlite 0.39 exists; refinery-core 0.9.1 caps at 0.38. -**Decision:** Pin both; bump together when refinery 0.10 ships. -**Alternatives:** Drop refinery and hand-roll migrations. -**Consequences:** Single coordinated upgrade later. - -## 2026-04-15: Migrations via `refinery` - -**Context:** Phase 1 will ship SQLite schema. -**Decision:** SQL-file-driven migrations via refinery. -**Alternatives:** `rusqlite_migration` (Rust-code closures). -**Consequences:** SQL as source of truth. - -## 2026-04-15: `Cargo.lock` is committed - -**Context:** Workspace ships binaries. -**Decision:** Commit Cargo.lock. -**Alternatives:** Gitignore it (library convention). -**Consequences:** Reproducible CI + contributor builds. - -## 2026-04-15: `missing_docs = "deny"` in workspace lints; `docs-coverage` is plain `cargo doc` - -**Context:** Need a hard failure on undocumented public items. -**Decision:** Set `[workspace.lints.rust] missing_docs = "deny"` so every `cargo build` and `cargo doc` hard-fails on undocumented public items. `docs-coverage` is just `cargo doc --workspace --no-deps` and additionally validates `broken_intra_doc_links` (which only fires under `cargo doc`). -**Alternatives:** `RUSTDOCFLAGS="-D rustdoc::missing_docs"` (rejected — `rustdoc::missing_docs` is not a real lint; `missing_docs` is a rustc lint, not a rustdoc lint, so the env-var approach was silently a no-op). `grep` parsing of doc output (fragile to message changes / i18n). -**Consequences:** Every build catches missing docs immediately, not just `cargo doc`. Verified via gate-proof: removing one `///` line on a public item produces EXIT:101 with "missing documentation for a constant"; restoring returns to EXIT:0. - -## 2026-04-15: CI installs `just` via `taiki-e/install-action@just` - -**Context:** `cargo install just` adds 30–90s per CI run before rust-cache activates. -**Decision:** Use taiki-e's prebuilt binary install. -**Alternatives:** Official install script; `cargo install just --locked`. -**Consequences:** ~1 min saved per CI run; trivially swappable if the action breaks. -``` - -- [ ] **Step 2: Verify untracked** - -Run: `git status --porcelain DECISIONS.md` -Expected: empty (file exists but ignored). - ---- - -## Task 12: Seed local `README.md` - -**Files:** -- Create: `README.md` (gitignored) - -- [ ] **Step 1: Create the file** - -```markdown -# perima - -Cross-platform media asset manager. See `CLAUDE.md` for project rules -and `docs/superpowers/specs/2026-04-15-meta-plan-design.md` for phase -sequencing. - -Local-only (gitignored). -``` - -- [ ] **Step 2: Verify untracked** - -Run: `git status --porcelain README.md` -Expected: empty. - ---- - -## Task 13: Final verification + push - -- [ ] **Step 1: Clean build** - -Run: `cargo clean && just ci` -Expected: green. - -- [ ] **Step 2: Scripted hook test** - -Run: `just test-hook` -Expected: green. - -- [ ] **Step 3: `.md` hygiene** - -Run: `git ls-files '*.md'` -Expected: empty. - -- [ ] **Step 4: Exit criteria checklist (from spec §Exit criteria)** - -- [ ] 1. `cargo build --workspace` — PASS -- [ ] 2. `cargo clippy --workspace --all-targets -- -D warnings` — PASS -- [ ] 3. `cargo test --workspace` — PASS -- [ ] 4. `cargo fmt --all -- --check` — PASS -- [ ] 5. `cargo doc --workspace --no-deps` zero missing-doc errors — PASS (enforced via `[workspace.lints.rust] missing_docs = "deny"` + `[workspace.lints.rustdoc] broken_intra_doc_links = "deny"`) -- [ ] 6. `just ci` — PASS -- [ ] 7. GitHub Actions `ci.yml` green (verified after push in step 7) -- [ ] 8. `.github/workflows/kani.yml` valid — PASS (`actionlint`) -- [ ] 9. `just test-hook` — PASS -- [ ] 10. `git ls-files '*.md'` empty — PASS - -- [ ] **Step 5: Reviewer (checkpoint #4 — final phase 0)** - -Reviewer checklist: -- [ ] All 10 spec exit criteria pass. -- [ ] No reopened must/should-fix from earlier review passes. -- [ ] `git status` clean. -- [ ] `DECISIONS.md` present locally with all 10 seeded entries. - -- [ ] **Step 6: Verify clean tree** - -Run: `git status` -Expected: clean. - -- [ ] **Step 7: Push (first push to origin/main)** - -Safe because Task 0 Step 5 already confirmed remote is clean (or was synced down). - -Run: `git push -u origin main` -Expected: creates `main` remotely (or fast-forwards). - -- [ ] **Step 8: Poll CI** - -Run: `gh run list --workflow=ci.yml --limit 1 --json status,conclusion,name` -Expected eventually: `{"conclusion":"success"}` on all three matrix jobs (2–5 min). - -If a runner fails: open a bugfix task for phase 0; do NOT mask with `continue-on-error`. Known mitigations: -- Windows line endings → already handled by `.gitattributes`. -- `/tmp` on Windows → already handled by `mktemp` in `docs-coverage`. -- `taiki-e/install-action` transient network failure → retry the job. - -- [ ] **Step 9: Tag phase boundary** - -Run: -```bash -git tag -a phase-0-complete -m "Phase 0: scaffold & gates complete" -git push origin phase-0-complete -``` - ---- - -## Self-review - -**Spec coverage:** -- §1 Workspace manifest → Task 1 ✓ -- §2 Empty crates → Tasks 1, 2 ✓ -- §3 justfile → Task 6 ✓ -- §4 Pre-commit hook → Task 7 ✓ -- §4a Scripted test → Task 8 ✓ -- §5 GitHub Actions → Tasks 9, 10 ✓ -- §6 rustfmt.toml → Task 3 ✓ -- §7 .gitattributes → Task 4 ✓ -- §7a .gitignore → Task 5 ✓ -- §8 DECISIONS.md seed → Task 11 ✓ -- §9 README.md → Task 12 ✓ -- Exit criteria 1–10 → Task 13 ✓ -- Environment preflight → Task 0 (plan addition) - -**Placeholder scan:** no TBD/TODO in any task; every file has exact content. - -**Type consistency:** crate names (`perima-core`, `perima-db`, `perima-fs`, `perima-hash`, `perima`) consistent across `Cargo.toml`, `src/lib.rs`, and `[[bin]]` paths. Reviewer checkpoint names (#1–#4) consistent. `docs-coverage` implementation consistent between spec §3 and justfile in Task 6. - -**Commit discipline:** four reviewer checkpoints (Tasks 6, 8, 10, 13) each gate a single commit; commit messages reference the spec. Push happens once, at the final checkpoint, after tag. diff --git a/docs/superpowers/plans/2026-04-16-phase-1a-core-scan-cli.md b/docs/superpowers/plans/2026-04-16-phase-1a-core-scan-cli.md deleted file mode 100644 index bda5449..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-1a-core-scan-cli.md +++ /dev/null @@ -1,2414 +0,0 @@ -# Phase 1a — Core Types, Ports, Scan-Without-DB Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship the hexagonal core (`BlakeHash`, `MediaPath`, `VolumeId`, trait ports, `CoreError`), the hash + filesystem adapters, and a CLI `perima scan --dry-run ` that walks + hashes + prints. No DB access; no watching; cross-cutting concerns (config, logging, Ctrl-C, panic hook) land here to prevent retrofits. - -**Architecture:** `crates/core` holds domain + trait ports with zero framework deps. `crates/hash` impls `HashService` via the `blake3` crate with rayon-parallel hashing. `crates/fs` impls `Scanner` via `walkdir` + `unicode-normalization` + `path-slash`. `crates/cli` composes adapters, installs panic/Ctrl-C handlers, and dispatches `perima scan --dry-run`. Sync throughout; rayon for CPU-bound hashing; no tokio yet (phase 3). - -**Tech Stack:** Rust 1.85+ edition 2024, blake3, walkdir, unicode-normalization, path-slash, dunce, rayon, ctrlc, clap v4 (derive), uuid v7, thiserror, anyhow, tracing + tracing-subscriber, directories, tempfile, insta, proptest. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md` - -**Execution rule (from CLAUDE.md):** All work on `main`. Per-commit order: execute → `just ci` green → reviewer subagent approves → commit. Never commit unreviewed work. No `--force`, no `--no-verify`, no worktrees, no branches. - ---- - -## File Structure - -**Created in this phase (all committed):** - -``` -Cargo.toml # modify — add deps -crates/core/ -├── Cargo.toml # modify — add deps -└── src/ - ├── lib.rs # modify — re-exports - ├── ids.rs # new - ├── errors.rs # new - ├── types.rs # new - └── ports/ - ├── mod.rs # new - ├── hash.rs # new - ├── scanner.rs # new - ├── file_repo.rs # new - └── volume_repo.rs # new - -crates/hash/ -├── Cargo.toml # modify — add deps -└── src/ - ├── lib.rs # modify — re-exports - ├── errors.rs # new - └── blake3_service.rs # new - -crates/fs/ -├── Cargo.toml # modify — add deps -└── src/ - ├── lib.rs # modify — re-exports - ├── errors.rs # new - ├── paths.rs # new - └── walker.rs # new - -crates/cli/ -├── Cargo.toml # modify — add deps -└── src/ - ├── main.rs # modify — full rewrite - ├── config.rs # new - ├── logging.rs # new - ├── signals.rs # new - ├── panic.rs # new - └── cmd/ - ├── mod.rs # new - └── scan.rs # new -└── tests/ - └── scan_dry_run.rs # new — integration test - -crates/core/tests/ -├── props_hash_determinism.rs # new -├── props_path_idempotence.rs # new -└── props_path_nfc_equivalence.rs # new -``` - -Reviewer checkpoints gate four commits: after core, after adapters, after CLI, after final tests + tag. - ---- - -## Task 1: Add workspace dependencies - -**Files:** -- Modify: `Cargo.toml:41-65` (append to `[workspace.dependencies]`) - -- [ ] **Step 1: Open Cargo.toml and insert new deps** - -Append these lines immediately before the `[profile.release]` section (they join the existing `[workspace.dependencies]` table): - -```toml -clap = { version = "4", features = ["derive"] } -tempfile = "3" -insta = { version = "1", features = ["yaml", "filters"] } -proptest = "1" -rayon = "1" -ctrlc = { version = "3", features = ["termination"] } -``` - -- [ ] **Step 2: Verify resolution** - -Run: `cargo metadata --format-version 1 >/dev/null` -Expected: exit 0. Cargo resolves the new deps without network errors. - -- [ ] **Step 3: Confirm `just ci` still green (no deps used yet)** - -Run: `just ci` -Expected: exit 0. - ---- - -## Task 2: `crates/core/Cargo.toml` — add deps + feature - -**Files:** -- Modify: `crates/core/Cargo.toml` - -- [ ] **Step 1: Add dependencies** - -Replace the `[dependencies]` section (currently empty) with: - -```toml -[dependencies] -thiserror.workspace = true -serde.workspace = true -uuid.workspace = true -unicode-normalization.workspace = true -``` - -- [ ] **Step 2: Verify core builds** - -Run: `cargo build -p perima-core` -Expected: exit 0. - ---- - -## Task 3: Core IDs helper (`crates/core/src/ids.rs`) - -**Files:** -- Create: `crates/core/src/ids.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Write the module** - -`crates/core/src/ids.rs`: - -```rust -//! UUIDv7 helpers. -//! -//! UUIDv7 (RFC 9562) is time-sortable with 48-bit ms timestamps; it -//! gives us globally unique IDs whose B-tree insertion order matches -//! creation order, avoiding SQLite index fragmentation. - -use uuid::Uuid; - -/// Generate a fresh UUIDv7. -#[must_use] -pub fn new_id() -> Uuid { - Uuid::now_v7() -} -``` - -- [ ] **Step 2: Re-export from lib.rs** - -Replace `crates/core/src/lib.rs` contents with: - -```rust -//! Domain types and trait ports for perima. -//! -//! This crate has zero framework dependencies. Every other crate in -//! the workspace either defines types consumed here or adapts this -//! crate's traits to a concrete backend. - -pub mod ids; - -/// Marker placeholder. Replaced with real domain types in phase 1. -pub const CRATE_NAME: &str = "perima-core"; -``` - -- [ ] **Step 3: Run the gates** - -Run: `just ci` -Expected: exit 0. - ---- - -## Task 4: Core errors (`crates/core/src/errors.rs`) - -**Files:** -- Create: `crates/core/src/errors.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Write the module** - -`crates/core/src/errors.rs`: - -```rust -//! Top-level error type crossing the core boundary. -//! -//! Adapters define their own internal errors and implement -//! `From for CoreError` **inside the adapter crate** -//! so that `core` depends on no adapter (preserves hexagonal -//! direction). - -use thiserror::Error; - -/// Error returned by every `core` trait method. -#[derive(Debug, Error)] -pub enum CoreError { - /// Queried item was absent. - #[error("not found: {0}")] - NotFound(String), - - /// App-level uniqueness check rejected an insert. - #[error("duplicate: {0}")] - Duplicate(String), - - /// Path string could not be normalized or is outside the expected root. - #[error("invalid path: {0}")] - InvalidPath(String), - - /// Hex input was not a valid 64-char lowercase BLAKE3 hash. - #[error("invalid hash hex: {0}")] - InvalidHash(String), - - /// Underlying I/O failure. - #[error("io: {0}")] - Io(#[from] std::io::Error), - - /// Feature is declared but not yet implemented at this phase. - /// Dedicated variant so `main.rs` can map to a stable exit code - /// without substring-matching prose. - #[error("unsupported in this phase: {0}")] - Unsupported(String), - - /// Any adapter-level failure that didn't map to a typed variant. - #[error("internal: {0}")] - Internal(String), -} -``` - -- [ ] **Step 2: Re-export from lib.rs** - -Replace `crates/core/src/lib.rs` contents: - -```rust -//! Domain types and trait ports for perima. -//! -//! This crate has zero framework dependencies. Every other crate in -//! the workspace either defines types consumed here or adapts this -//! crate's traits to a concrete backend. - -pub mod errors; -pub mod ids; - -pub use errors::CoreError; - -/// Marker placeholder. Replaced with real domain types in phase 1. -pub const CRATE_NAME: &str = "perima-core"; -``` - -- [ ] **Step 3: Run the gates** - -Run: `just ci` -Expected: exit 0. - ---- - -## Task 5: Core types — `BlakeHash` + `FileSize` (TDD) - -**Files:** -- Create: `crates/core/src/types.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Write failing unit tests** - -Create `crates/core/src/types.rs` with just the test module first: - -```rust -//! Domain value types. - -// tests defined below force the impl shape - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn blake_hash_round_trip() { - let bytes = [0x42u8; 32]; - let h = BlakeHash::from_bytes(bytes); - let s = h.to_hex(); - assert_eq!(s.len(), 64); - assert!(s.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); - let parsed = BlakeHash::parse_hex(&s).expect("parse_hex round-trip"); - assert_eq!(parsed, h); - assert_eq!(parsed.as_bytes(), &bytes); - } - - #[test] - fn blake_hash_rejects_wrong_length() { - assert!(BlakeHash::parse_hex("abc").is_err()); - assert!(BlakeHash::parse_hex(&"a".repeat(63)).is_err()); - assert!(BlakeHash::parse_hex(&"a".repeat(65)).is_err()); - } - - #[test] - fn blake_hash_rejects_non_hex() { - let bad: String = "Z".repeat(64); - assert!(BlakeHash::parse_hex(&bad).is_err()); - } - - #[test] - fn blake_hash_rejects_uppercase() { - let upper: String = "A".repeat(64); - assert!(BlakeHash::parse_hex(&upper).is_err()); - } - - #[test] - fn file_size_is_copy() { - let a = FileSize(1024); - let b = a; - assert_eq!(a.0, b.0); - } -} -``` - -- [ ] **Step 2: Run — expect compile fail** - -Run: `cargo test -p perima-core --lib` -Expected: compile error "cannot find type `BlakeHash`". - -- [ ] **Step 3: Implement `BlakeHash` + `FileSize`** - -Replace `crates/core/src/types.rs` with: - -```rust -//! Domain value types. -//! -//! WHY (content-addressed PK, landed at the migration in phase 1b): -//! `files.blake3_hash` will be the primary key on the `files` table -//! even though CLAUDE.md mandates UUIDv7 PKs. A BLAKE3-256 hash is -//! deterministic and content-derived — two devices hashing identical -//! bytes MUST compute the same value, so using it as a PK satisfies -//! the CRDT-merge invariant that the UUIDv7 rule exists to enforce -//! (no accidental divergence). A content hash is effectively a -//! deterministic UUID whose generation function is "hash the bytes"; -//! the merge is free. The UUIDv7 rule applies only to rows whose -//! identity is NOT content-derived (volumes, locations, mounts). -//! This comment ships in 1a so 1b's migration reproduces the -//! rationale verbatim. - -use serde::{Deserialize, Serialize}; - -use crate::errors::CoreError; - -/// BLAKE3-256 content hash (32 bytes). Stored as lowercase hex at -/// the persistence boundary. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct BlakeHash([u8; 32]); - -impl BlakeHash { - /// Construct from raw bytes. - #[must_use] - pub fn from_bytes(b: [u8; 32]) -> Self { - Self(b) - } - - /// Render as 64-char lowercase hex. - #[must_use] - pub fn to_hex(&self) -> String { - let mut out = String::with_capacity(64); - for byte in self.0 { - // Hand-rolled to guarantee lowercase without depending on fmt quirks. - out.push(nibble_to_hex(byte >> 4)); - out.push(nibble_to_hex(byte & 0x0f)); - } - out - } - - /// Parse from 64-char lowercase hex. Uppercase hex is rejected - /// so the DB form is stable. - /// - /// # Errors - /// Returns `CoreError::InvalidHash` on wrong length, non-hex - /// characters, or uppercase letters. - pub fn parse_hex(s: &str) -> Result { - if s.len() != 64 { - return Err(CoreError::InvalidHash(format!( - "expected 64 chars, got {}", - s.len() - ))); - } - let mut out = [0u8; 32]; - let bytes = s.as_bytes(); - for i in 0..32 { - let hi = parse_nibble(bytes[i * 2]).ok_or_else(|| { - CoreError::InvalidHash(format!("invalid char at {}", i * 2)) - })?; - let lo = parse_nibble(bytes[i * 2 + 1]).ok_or_else(|| { - CoreError::InvalidHash(format!("invalid char at {}", i * 2 + 1)) - })?; - out[i] = (hi << 4) | lo; - } - Ok(Self(out)) - } - - /// Raw byte view. - #[must_use] - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } -} - -const fn nibble_to_hex(n: u8) -> char { - match n { - 0..=9 => (b'0' + n) as char, - 10..=15 => (b'a' + (n - 10)) as char, - // WHY: unreachable only because callers mask to 4 bits. If this ever - // fires in prod, the bitwise masks in `to_hex` were broken. - _ => unreachable!(), - } -} - -const fn parse_nibble(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - // WHY: uppercase rejected so persisted form is case-stable. - _ => None, - } -} - -/// File size in bytes. Newtype to prevent arithmetic with other u64s. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct FileSize(pub u64); - -#[cfg(test)] -mod tests { /* see above */ } -``` - -Note: the `#[cfg(test)] mod tests` at the bottom must contain the tests written in Step 1. Keep them verbatim; the module was truncated above for brevity, but the real file has both the implementation AND the tests. - -- [ ] **Step 4: Re-export from lib.rs** - -Add `pub mod types;` plus `pub use types::{BlakeHash, FileSize};` to `crates/core/src/lib.rs`. - -- [ ] **Step 5: Run the gates** - -Run: `cargo test -p perima-core --lib` → expect 5 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 6: Core types — `MediaPath` (TDD) - -**Files:** -- Modify: `crates/core/src/types.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Write failing tests** - -Append to `crates/core/src/types.rs` `mod tests`: - -```rust -#[test] -fn media_path_strips_leading_slash() { - assert_eq!(MediaPath::new("/photos/a.jpg").as_str(), "photos/a.jpg"); - assert_eq!(MediaPath::new("///photos/a.jpg").as_str(), "photos/a.jpg"); -} - -#[test] -fn media_path_forward_slashes() { - assert_eq!( - MediaPath::new("photos\\2024\\a.jpg").as_str(), - "photos/2024/a.jpg" - ); -} - -#[test] -fn media_path_nfc_equivalence_fixed() { - // "café" — precomposed (NFC) vs decomposed (NFD). - let nfc = "caf\u{00E9}"; - let nfd = "cafe\u{0301}"; - assert_eq!(MediaPath::new(nfc), MediaPath::new(nfd)); -} - -#[test] -fn media_path_idempotent_fixed_cases() { - for s in &["photos/a.jpg", "a", "", "caf\u{0301}", "a/b/c"] { - let once = MediaPath::new(s); - let twice = MediaPath::new(once.as_str()); - assert_eq!(once, twice); - } -} -``` - -- [ ] **Step 2: Run — expect compile fail** - -Run: `cargo test -p perima-core --lib` -Expected: compile error "cannot find type `MediaPath`". - -- [ ] **Step 3: Implement `MediaPath`** - -Add to `crates/core/src/types.rs` (above the `#[cfg(test)]` block): - -```rust -/// Path relative to a volume root. NFC-normalized, forward-slash, -/// no leading slash. The constructor is *idempotent* AND makes -/// canonically-equivalent inputs compare equal (NFC = NFD). -/// -/// WHY: the combination of (NFC normalization + forward-slash -/// conversion + leading-slash strip) in one pass is what makes -/// the constructor simultaneously idempotent AND case-canonical -/// under Unicode equivalence. Splitting these into separate -/// passes would preserve idempotence but break equivalence -/// (NFC-then-slash-fix would still differ from slash-fix-then-NFC -/// on edge cases involving combining marks inside path segments). -#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct MediaPath(String); - -impl MediaPath { - /// Construct a normalized `MediaPath` from a raw string. - #[must_use] - pub fn new(raw: &str) -> Self { - use unicode_normalization::UnicodeNormalization; - let nfc: String = raw.nfc().collect(); - let slashed = nfc.replace('\\', "/"); - let trimmed = slashed.trim_start_matches('/').to_owned(); - Self(trimmed) - } - - /// Borrow the normalized string. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} -``` - -- [ ] **Step 4: Run the gates** - -Run: `cargo test -p perima-core --lib` → 9 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 7: Core types — `VolumeId`, `DeviceId`, records - -**Files:** -- Modify: `crates/core/src/types.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Append types + tests** - -Add to `crates/core/src/types.rs` (above the test block): - -```rust -use std::path::PathBuf; - -/// UUIDv7 volume identifier. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct VolumeId(pub uuid::Uuid); - -impl VolumeId { - /// Generate a new UUIDv7-backed volume id. - #[must_use] - pub fn new() -> Self { - Self(crate::ids::new_id()) - } -} - -impl Default for VolumeId { - fn default() -> Self { - Self::new() - } -} - -/// UUIDv7 device identifier. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct DeviceId(pub uuid::Uuid); - -impl DeviceId { - /// Generate a new UUIDv7-backed device id (used on first run). - #[must_use] - pub fn new() -> Self { - Self(crate::ids::new_id()) - } -} - -impl Default for DeviceId { - fn default() -> Self { - Self::new() - } -} - -/// Output of the scanner; pre-hash. -#[derive(Clone, Debug)] -pub struct DiscoveredFile { - /// Absolute path as observed during the walk. - pub absolute_path: PathBuf, - /// Path relative to the volume root, NFC-normalized. - pub relative_path: MediaPath, - /// File size in bytes at walk time. - pub size: FileSize, -} - -/// Post-hash pipeline record. -#[derive(Clone, Debug)] -pub struct HashedFile { - /// The scanner output that produced this record. - pub discovered: DiscoveredFile, - /// BLAKE3-256 content hash of the file contents. - pub hash: BlakeHash, -} - -/// Status of a file location row. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum LocationStatus { - /// Visible on the expected volume at the expected path. - Active, - /// The path was not found on the last verification. - Missing, - /// The file has moved elsewhere on the same volume. - Moved, -} - -/// Outcome of a repository upsert. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum UpsertOutcome { - /// Row did not exist and was inserted. - Inserted, - /// Row existed and was updated. - Updated, - /// Row existed and matched; no write performed. - Unchanged, -} - -/// Row returned by `FileRepository::list_file_locations`. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct FileLocationRecord { - /// Content hash of the underlying file. - pub hash: BlakeHash, - /// File size in bytes. - pub size: FileSize, - /// Volume the location lives on. - pub volume_id: VolumeId, - /// Relative path within the volume. - pub relative_path: MediaPath, - /// Location status. - pub status: LocationStatus, - /// ISO 8601 UTC timestamp of first sighting. - pub first_seen: String, -} - -/// Observed identifiers for a volume during detection (phase 1c fills). -#[derive(Clone, Debug)] -pub struct VolumeIdentifiers { - /// GPT partition GUID if available. - pub gpt_partition_guid: Option, - /// Filesystem UUID if available. - pub fs_uuid: Option, - /// Human-readable volume label if available. - pub label: Option, - /// Total capacity in bytes. - pub capacity_bytes: u64, - /// Whether the OS reports this as removable. - pub is_removable: bool, -} - -/// Row returned by `VolumeRepository::list`. -#[derive(Clone, Debug)] -pub struct VolumeRecord { - /// Volume id. - pub id: VolumeId, - /// Label if any. - pub label: Option, - /// Capacity in bytes. - pub capacity_bytes: u64, - /// Removable flag. - pub is_removable: bool, - /// Current mount paths on this machine. - pub mounts_on_this_machine: Vec, - /// ISO 8601 UTC timestamp of last sighting. - pub last_seen: String, -} -``` - -And add a small test to `mod tests`: - -```rust -#[test] -fn volume_id_new_is_unique() { - let a = VolumeId::new(); - let b = VolumeId::new(); - assert_ne!(a.0, b.0); -} -``` - -- [ ] **Step 2: Re-export from lib.rs** - -Replace `crates/core/src/lib.rs` with: - -```rust -//! Domain types and trait ports for perima. -//! -//! Zero framework dependencies. - -pub mod errors; -pub mod ids; -pub mod types; - -pub use errors::CoreError; -pub use types::{ - BlakeHash, DeviceId, DiscoveredFile, FileLocationRecord, FileSize, HashedFile, - LocationStatus, MediaPath, UpsertOutcome, VolumeId, VolumeIdentifiers, VolumeRecord, -}; - -/// Marker placeholder. Retained as a public symbol for phase-0 -/// compatibility tests; will be removed in phase 1b when the real -/// public surface covers it. -pub const CRATE_NAME: &str = "perima-core"; -``` - -- [ ] **Step 3: Run the gates** - -Run: `cargo test -p perima-core` → 10 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 8: Core ports - -**Files:** -- Create: `crates/core/src/ports/mod.rs` -- Create: `crates/core/src/ports/hash.rs` -- Create: `crates/core/src/ports/scanner.rs` -- Create: `crates/core/src/ports/file_repo.rs` -- Create: `crates/core/src/ports/volume_repo.rs` -- Modify: `crates/core/src/lib.rs` - -- [ ] **Step 1: Create `ports/mod.rs`** - -```rust -//! Trait ports — the hexagonal boundary between core and adapters. - -pub mod file_repo; -pub mod hash; -pub mod scanner; -pub mod volume_repo; - -pub use file_repo::FileRepository; -pub use hash::HashService; -pub use scanner::Scanner; -pub use volume_repo::VolumeRepository; -``` - -- [ ] **Step 2: Create `ports/hash.rs`** - -```rust -//! Hash service port. - -use std::path::Path; - -use crate::{BlakeHash, CoreError}; - -/// BLAKE3-based content hashing. -/// -/// WHY `Send + Sync`: although phase 1a is entirely synchronous, the -/// scan loop uses `rayon` to parallelize `full_hash` calls across -/// files. Keeping the trait `Send + Sync` also leaves room for a -/// future async adapter without breaking the trait. -pub trait HashService: Send + Sync { - /// Hash only the first 64 KiB. Cheap change-detection used by - /// the phase 3 watcher. Phase 1a callers always use `full_hash`. - /// - /// # Errors - /// Returns `CoreError::Io` on read failures. - fn quick_hash(&self, path: &Path) -> Result; - - /// Hash the entire file. - /// - /// # Errors - /// Returns `CoreError::Io` on read failures. - fn full_hash(&self, path: &Path) -> Result; -} -``` - -- [ ] **Step 3: Create `ports/scanner.rs`** - -```rust -//! Filesystem scanner port. - -use std::path::Path; - -use crate::{CoreError, DiscoveredFile}; - -/// Walks a directory tree and produces `DiscoveredFile`s. -pub trait Scanner: Send + Sync { - /// Walk `root` recursively. Per-entry errors are logged via - /// `tracing` and skipped (the iterator continues). Only - /// terminal failures (e.g. permission denied on the root) - /// return `Err`. - /// - /// `volume_root` is used to compute each file's relative path. - /// - /// # Errors - /// Returns `CoreError::Io` if `root` cannot be opened, or - /// `CoreError::InvalidPath` if `volume_root` is not a prefix - /// of `root`. - fn walk<'a>( - &'a self, - root: &Path, - volume_root: &Path, - ) -> Result + Send + 'a>, CoreError>; -} -``` - -- [ ] **Step 4: Create `ports/file_repo.rs`** - -```rust -//! File + location repository port (implementations land in phase 1b). - -use crate::{ - BlakeHash, CoreError, DeviceId, FileLocationRecord, HashedFile, MediaPath, - UpsertOutcome, VolumeId, -}; - -/// Persistence boundary for `files` + `file_locations`. -pub trait FileRepository: Send + Sync { - /// Upsert the content-addressed `files` row. - /// - /// # Errors - /// Adapter-level errors are surfaced as `CoreError::Internal` - /// unless they map to a typed variant. - fn upsert_file( - &mut self, - file: &HashedFile, - device: DeviceId, - ) -> Result; - - /// Upsert a `file_locations` row for `(volume, relative_path)`. - /// - /// # Errors - /// Returns `CoreError::Duplicate` if the app-level uniqueness - /// check rejects the row. - fn upsert_location( - &mut self, - hash: &BlakeHash, - volume: VolumeId, - path: &MediaPath, - device: DeviceId, - ) -> Result; - - /// List `(file, location)` joins. Used by `perima ls` in phase 1b. - /// - /// # Errors - /// Adapter-level errors become `CoreError::Internal`. - fn list_file_locations( - &self, - limit: usize, - volume: Option, - ) -> Result, CoreError>; -} -``` - -- [ ] **Step 5: Create `ports/volume_repo.rs`** - -```rust -//! Volume + volume-mount repository port (implementations in 1b/1c). - -use std::path::Path; - -use crate::{CoreError, DeviceId, VolumeId, VolumeIdentifiers, VolumeRecord}; - -/// Persistence boundary for `volumes` + `volume_mounts`. -pub trait VolumeRepository: Send + Sync { - /// Find a known volume matching the observed identifiers, or - /// create a new one. - /// - /// # Errors - /// `CoreError::Internal` on adapter failure. - fn find_or_create( - &mut self, - ident: &VolumeIdentifiers, - device: DeviceId, - ) -> Result; - - /// Record the current mount path for `volume` on `machine`. - /// - /// # Errors - /// `CoreError::Internal` on adapter failure. - fn record_mount( - &mut self, - volume: VolumeId, - machine: DeviceId, - mount: &Path, - ) -> Result<(), CoreError>; - - /// Enumerate all known volumes with their current mounts on this - /// machine. - /// - /// # Errors - /// `CoreError::Internal` on adapter failure. - fn list(&self) -> Result, CoreError>; -} -``` - -- [ ] **Step 6: Update `lib.rs`** - -Append to `crates/core/src/lib.rs`: - -```rust -pub mod ports; -pub use ports::{FileRepository, HashService, Scanner, VolumeRepository}; -``` - -- [ ] **Step 7: Dispatch reviewer subagent (checkpoint #1 — commit 1 prep)** - -Reviewer checklist: -- [ ] `cargo build -p perima-core` exit 0. -- [ ] `cargo clippy --workspace --all-targets -- -D warnings` exit 0. -- [ ] `cargo test -p perima-core` passes (≥ 10 tests). -- [ ] `cargo doc --workspace --no-deps` exit 0 (no `missing_docs`). -- [ ] Every pub item in `crates/core` has a doc comment. -- [ ] `// WHY:` comments present for: `MediaPath::new` combining normalizations, `HashService: Send + Sync`. - -Must return APPROVED before Step 8. - -- [ ] **Step 8: Commit (after APPROVED)** - -```bash -git add Cargo.toml Cargo.lock crates/core/ -git commit -m "$(cat <<'EOF' -feat(phase-1a): core types + ports + errors + ids - -crates/core lands with zero framework deps: BlakeHash (content -hash), MediaPath (NFC + forward-slash + strip-leading normalization, -idempotent + NFC-equivalent under repeated application), VolumeId/ -DeviceId (UUIDv7), DiscoveredFile/HashedFile pipeline records, -FileLocationRecord join view, LocationStatus enum, UpsertOutcome, -VolumeIdentifiers/VolumeRecord supporting types. - -Ports: HashService (quick_hash + full_hash), Scanner (walk → -iterator of DiscoveredFile), FileRepository (upsert_file, -upsert_location, list_file_locations), VolumeRepository -(find_or_create, record_mount, list). Ports 3–4 are shape-only in -1a; implementations arrive in 1b/1c. - -CoreError with thiserror; adapters will implement -From for CoreError inside their own crates to -preserve the hexagonal dependency direction. - -Refs: docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 9: Hash adapter (`crates/hash`) - -**Files:** -- Modify: `crates/hash/Cargo.toml` -- Modify: `crates/hash/src/lib.rs` -- Create: `crates/hash/src/errors.rs` -- Create: `crates/hash/src/blake3_service.rs` - -- [ ] **Step 1: Update `crates/hash/Cargo.toml`** - -Replace `[dependencies]` section with: - -```toml -[dependencies] -perima-core = { path = "../core" } -thiserror.workspace = true -blake3.workspace = true -rayon.workspace = true -tracing.workspace = true -``` - -- [ ] **Step 2: Create `crates/hash/src/errors.rs`** - -```rust -//! Internal errors for the hash adapter. - -use thiserror::Error; - -/// Errors raised inside `perima-hash`. -#[derive(Debug, Error)] -pub enum Error { - /// I/O failure while reading a file. - #[error("io: {0}")] - Io(#[from] std::io::Error), -} - -impl From for perima_core::CoreError { - fn from(e: Error) -> Self { - match e { - Error::Io(io) => perima_core::CoreError::Io(io), - } - } -} -``` - -- [ ] **Step 3: Create `crates/hash/src/blake3_service.rs` with tests first (TDD)** - -```rust -//! BLAKE3 content-hashing service. - -use std::io::Read; -use std::path::Path; - -use perima_core::{BlakeHash, CoreError, HashService}; - -use crate::errors::Error; - -/// Default chunk size for streaming reads. BLAKE3 is fastest when -/// fed reasonably-large chunks. -const CHUNK_SIZE: usize = 64 * 1024; - -/// First-64-KiB cap used by `quick_hash`. -const QUICK_CAP: u64 = 64 * 1024; - -/// `HashService` implementation backed by the `blake3` crate. -#[derive(Clone, Copy, Debug, Default)] -pub struct Blake3Service; - -impl Blake3Service { - /// Construct a stateless hasher. - #[must_use] - pub fn new() -> Self { - Self - } -} - -impl HashService for Blake3Service { - fn quick_hash(&self, path: &Path) -> Result { - hash_file(path, Some(QUICK_CAP)).map_err(Into::into) - } - - fn full_hash(&self, path: &Path) -> Result { - hash_file(path, None).map_err(Into::into) - } -} - -fn hash_file(path: &Path, cap: Option) -> Result { - let mut file = std::fs::File::open(path)?; - let mut hasher = blake3::Hasher::new(); - let mut buf = [0u8; CHUNK_SIZE]; - let mut remaining = cap.unwrap_or(u64::MAX); - while remaining > 0 { - let want = std::cmp::min(buf.len() as u64, remaining) as usize; - let n = file.read(&mut buf[..want])?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - remaining = remaining.saturating_sub(n as u64); - } - let out = hasher.finalize(); - Ok(BlakeHash::from_bytes(*out.as_bytes())) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn deterministic_full_hash() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("f.bin"); - std::fs::File::create(&path) - .expect("create") - .write_all(b"hello world") - .expect("write"); - let svc = Blake3Service::new(); - let a = svc.full_hash(&path).expect("hash1"); - let b = svc.full_hash(&path).expect("hash2"); - assert_eq!(a, b); - } - - #[test] - fn full_hash_matches_blake3_reference() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("f.bin"); - std::fs::File::create(&path) - .expect("create") - .write_all(b"hello world") - .expect("write"); - let svc = Blake3Service::new(); - let got = svc.full_hash(&path).expect("hash"); - let expected = blake3::hash(b"hello world"); - assert_eq!(got.as_bytes(), expected.as_bytes()); - } - - #[test] - fn quick_hash_caps_at_64kib() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("big.bin"); - let payload = vec![0x42u8; 128 * 1024]; - std::fs::File::create(&path) - .expect("create") - .write_all(&payload) - .expect("write"); - let svc = Blake3Service::new(); - let q = svc.quick_hash(&path).expect("quick"); - let expected = blake3::hash(&payload[..64 * 1024]); - assert_eq!(q.as_bytes(), expected.as_bytes()); - } -} -``` - -Add `tempfile` to `[dev-dependencies]` in `crates/hash/Cargo.toml`: - -```toml -[dev-dependencies] -tempfile.workspace = true -``` - -- [ ] **Step 4: Update `crates/hash/src/lib.rs`** - -```rust -//! BLAKE3-based content-hashing adapter for perima. - -pub mod blake3_service; -pub mod errors; - -pub use blake3_service::Blake3Service; -pub use errors::Error; - -/// Marker placeholder retained for phase-0 compatibility. -pub const CRATE_NAME: &str = "perima-hash"; -``` - -- [ ] **Step 5: Run the gates** - -Run: `cargo test -p perima-hash` → 3 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 10: FS adapter — errors + paths (TDD) - -**Files:** -- Modify: `crates/fs/Cargo.toml` -- Modify: `crates/fs/src/lib.rs` -- Create: `crates/fs/src/errors.rs` -- Create: `crates/fs/src/paths.rs` - -- [ ] **Step 1: Update `crates/fs/Cargo.toml`** - -```toml -[dependencies] -perima-core = { path = "../core" } -thiserror.workspace = true -walkdir.workspace = true -path-slash.workspace = true -dunce.workspace = true -tracing.workspace = true - -[dev-dependencies] -tempfile.workspace = true -``` - -- [ ] **Step 2: Create `errors.rs`** - -```rust -//! Internal errors for the filesystem adapter. - -use std::path::PathBuf; - -use thiserror::Error; - -/// Errors raised inside `perima-fs`. -#[derive(Debug, Error)] -pub enum Error { - /// I/O failure walking the filesystem. - #[error("io: {0}")] - Io(#[from] std::io::Error), - - /// A discovered path was not under the declared volume root. - #[error("path not under volume root: {0}")] - NotUnderVolume(PathBuf), -} - -impl From for perima_core::CoreError { - fn from(e: Error) -> Self { - match e { - Error::Io(io) => perima_core::CoreError::Io(io), - Error::NotUnderVolume(p) => { - perima_core::CoreError::InvalidPath(p.display().to_string()) - } - } - } -} -``` - -- [ ] **Step 3: Create `paths.rs` with tests first** - -```rust -//! Filesystem-level path helpers — resolve absolute paths to -//! volume-relative `MediaPath`s. - -use std::path::Path; - -use perima_core::MediaPath; - -use crate::errors::Error; - -/// Convert `absolute` into a `MediaPath` relative to `volume_root`. -/// -/// `absolute` must be under `volume_root`; callers should pre- -/// canonicalize both sides to avoid symlink surprises. -/// -/// # Errors -/// Returns `Error::NotUnderVolume` if `absolute` is not prefixed by -/// `volume_root`. -pub fn relativize(absolute: &Path, volume_root: &Path) -> Result { - let abs = dunce::simplified(absolute); - let root = dunce::simplified(volume_root); - let rel = abs - .strip_prefix(root) - .map_err(|_| Error::NotUnderVolume(absolute.to_path_buf()))?; - // WHY: convert to forward-slash explicitly before handing to - // MediaPath so our Windows test cases normalize consistently - // on non-Windows hosts. - let as_str = path_slash::PathExt::to_slash_lossy(rel); - Ok(MediaPath::new(as_str.as_ref())) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn relativize_simple() { - let root = std::path::Path::new("/tmp/perima"); - let abs = std::path::Path::new("/tmp/perima/photos/a.jpg"); - let r = relativize(abs, root).expect("relativize"); - assert_eq!(r.as_str(), "photos/a.jpg"); - } - - #[test] - fn relativize_rejects_outside_root() { - let root = std::path::Path::new("/tmp/perima"); - let abs = std::path::Path::new("/tmp/other/a.jpg"); - assert!(relativize(abs, root).is_err()); - } - - #[test] - fn relativize_identity_root() { - let root = std::path::Path::new("/tmp/perima"); - let r = relativize(root, root).expect("relativize root"); - assert_eq!(r.as_str(), ""); - } -} -``` - -- [ ] **Step 4: Update `crates/fs/src/lib.rs`** - -```rust -//! Filesystem scanning, watching, and path normalization for perima. - -pub mod errors; -pub mod paths; - -pub use errors::Error; -pub use paths::relativize; - -/// Marker placeholder retained for phase-0 compatibility. -pub const CRATE_NAME: &str = "perima-fs"; -``` - -- [ ] **Step 5: Run the gates** - -Run: `cargo test -p perima-fs` → 3 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 11: FS adapter — walker (TDD) - -**Files:** -- Create: `crates/fs/src/walker.rs` -- Modify: `crates/fs/src/lib.rs` - -- [ ] **Step 1: Write the walker + tests** - -`crates/fs/src/walker.rs`: - -```rust -//! Recursive filesystem walker implementing `Scanner`. - -use std::path::Path; - -use perima_core::{CoreError, DiscoveredFile, FileSize, Scanner}; - -use crate::{errors::Error, paths::relativize}; - -/// `walkdir`-backed scanner. -#[derive(Clone, Copy, Debug, Default)] -pub struct WalkdirScanner; - -impl WalkdirScanner { - /// Construct a stateless walker. - #[must_use] - pub fn new() -> Self { - Self - } -} - -impl Scanner for WalkdirScanner { - fn walk<'a>( - &'a self, - root: &Path, - volume_root: &Path, - ) -> Result + Send + 'a>, CoreError> { - // Smoke-check the root exists before we return the iterator - // so callers get a terminal error immediately rather than - // an empty stream. - if !root.exists() { - return Err(Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("root does not exist: {}", root.display()), - )) - .into()); - } - - let owned_volume_root = volume_root.to_path_buf(); - let iter = walkdir::WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_map(move |entry| match entry { - Ok(e) => { - if !e.file_type().is_file() { - return None; - } - let metadata = match e.metadata() { - Ok(m) => m, - Err(err) => { - tracing::warn!( - path = %e.path().display(), - error = %err, - "skipping entry: cannot read metadata" - ); - return None; - } - }; - let rel = match relativize(e.path(), &owned_volume_root) { - Ok(r) => r, - Err(err) => { - tracing::warn!( - path = %e.path().display(), - error = %err, - "skipping entry: cannot relativize" - ); - return None; - } - }; - Some(DiscoveredFile { - absolute_path: e.path().to_path_buf(), - relative_path: rel, - size: FileSize(metadata.len()), - }) - } - Err(err) => { - tracing::warn!(error = %err, "skipping entry: walk error"); - None - } - }); - Ok(Box::new(iter)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn mk_file(dir: &Path, name: &str, bytes: &[u8]) { - let path = dir.join(name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create dirs"); - } - std::fs::File::create(&path) - .expect("create file") - .write_all(bytes) - .expect("write"); - } - - #[test] - fn walks_three_files() { - let td = tempfile::tempdir().expect("tempdir"); - let root = td.path(); - mk_file(root, "a.txt", b"alpha"); - mk_file(root, "sub/b.txt", b"beta"); - mk_file(root, "sub/c.bin", b"gamma"); - - let scanner = WalkdirScanner::new(); - let mut names: Vec = scanner - .walk(root, root) - .expect("walk") - .map(|f| f.relative_path.as_str().to_owned()) - .collect(); - names.sort(); - assert_eq!(names, vec!["a.txt", "sub/b.txt", "sub/c.bin"]); - } - - #[test] - fn missing_root_is_err() { - let scanner = WalkdirScanner::new(); - let bogus = std::path::Path::new("/definitely/does/not/exist/perima-test"); - assert!(scanner.walk(bogus, bogus).is_err()); - } - - #[test] - fn sizes_are_populated() { - let td = tempfile::tempdir().expect("tempdir"); - mk_file(td.path(), "f.bin", &vec![0x00; 1024]); - let scanner = WalkdirScanner::new(); - let files: Vec<_> = scanner.walk(td.path(), td.path()).expect("walk").collect(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].size.0, 1024); - } -} -``` - -- [ ] **Step 2: Re-export from `crates/fs/src/lib.rs`** - -Add `pub mod walker;` and `pub use walker::WalkdirScanner;`. - -- [ ] **Step 3: Dispatch reviewer (checkpoint #2 — commit 2 prep)** - -Reviewer checklist: -- [ ] `crates/hash`: `Blake3Service`, `hash::Error`, `From`, 3 tests pass. -- [ ] `crates/fs`: `relativize`, `WalkdirScanner`, `fs::Error`, `From`, 6 tests pass. -- [ ] Both adapters depend on `perima-core` but not on each other. -- [ ] `just ci` green. -- [ ] WHY-comment present for `path_slash` explicit slash conversion. - -Must return APPROVED before Step 4. - -- [ ] **Step 4: Commit (after APPROVED)** - -```bash -git add crates/hash/ crates/fs/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-1a): hash + fs adapters - -crates/hash: Blake3Service impls HashService. Streaming reader -with 64 KiB chunks; quick_hash caps at first 64 KiB (phase 3 -change-detection path); full_hash runs unbounded. hash::Error -implements From for CoreError inside the adapter crate. - -crates/fs: relativize() resolves absolute paths to volume-relative -MediaPath via dunce (Windows UNC fix) + path-slash (forward-slash -convention). WalkdirScanner impls Scanner; per-entry errors are -logged via tracing and skipped (the iterator continues), only -terminal failures abort. - -Refs: docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 12: CLI — `config` + `logging` modules - -**Files:** -- Modify: `crates/cli/Cargo.toml` -- Create: `crates/cli/src/config.rs` -- Create: `crates/cli/src/logging.rs` - -- [ ] **Step 1: Update `crates/cli/Cargo.toml`** - -```toml -[dependencies] -perima-core = { path = "../core" } -perima-hash = { path = "../hash" } -perima-fs = { path = "../fs" } - -clap.workspace = true -anyhow.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -directories.workspace = true -ctrlc.workspace = true -rayon.workspace = true -uuid.workspace = true - -[dev-dependencies] -tempfile.workspace = true -insta.workspace = true -``` - -- [ ] **Step 2: Create `config.rs`** - -```rust -//! Runtime configuration: data dir, config dir, device id. - -use std::path::{Path, PathBuf}; - -use perima_core::{CoreError, DeviceId, ids}; - -/// Resolved configuration for a CLI invocation. -#[derive(Clone, Debug)] -pub struct Config { - /// Where the main database will live (1b uses this). - pub data_dir: PathBuf, - /// Where `device_id.txt` and future user config live. - pub config_dir: PathBuf, - /// Stable device identifier. - pub device_id: DeviceId, -} - -impl Config { - /// Resolve from the `directories` crate, then env overrides - /// (`PERIMA_DATA_DIR`, `PERIMA_CONFIG_DIR`), then CLI overrides. - /// Creates `/device_id.txt` on first run. - /// - /// # Errors - /// Returns `CoreError::Internal` if platform directories cannot - /// be resolved, or `CoreError::Io` on filesystem failures. - pub fn resolve(cli_data_dir: Option) -> Result { - let dirs = directories::ProjectDirs::from("dev", "perima", "perima") - .ok_or_else(|| CoreError::Internal("cannot resolve project dirs".into()))?; - - let config_dir = std::env::var_os("PERIMA_CONFIG_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| dirs.config_dir().to_path_buf()); - let data_dir = cli_data_dir - .or_else(|| std::env::var_os("PERIMA_DATA_DIR").map(PathBuf::from)) - .unwrap_or_else(|| dirs.data_dir().to_path_buf()); - - std::fs::create_dir_all(&config_dir)?; - std::fs::create_dir_all(&data_dir)?; - - let device_id = load_or_create_device_id(&config_dir)?; - Ok(Self { data_dir, config_dir, device_id }) - } -} - -fn load_or_create_device_id(config_dir: &Path) -> Result { - let path = config_dir.join("device_id.txt"); - if path.exists() { - let raw = std::fs::read_to_string(&path)?; - let trimmed = raw.trim(); - let parsed = uuid::Uuid::parse_str(trimmed) - .map_err(|e| CoreError::Internal(format!("device_id parse: {e}")))?; - return Ok(DeviceId(parsed)); - } - let id = ids::new_id(); - std::fs::write(&path, id.to_string())?; - Ok(DeviceId(id)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn device_id_persists_across_calls() { - let tmp = tempfile::tempdir().expect("tempdir"); - // SAFETY: single-threaded test — no race on env. - unsafe { - std::env::set_var("PERIMA_CONFIG_DIR", tmp.path()); - std::env::set_var("PERIMA_DATA_DIR", tmp.path()); - } - let a = Config::resolve(None).expect("resolve 1"); - let b = Config::resolve(None).expect("resolve 2"); - assert_eq!(a.device_id.0, b.device_id.0); - unsafe { - std::env::remove_var("PERIMA_CONFIG_DIR"); - std::env::remove_var("PERIMA_DATA_DIR"); - } - } -} -``` - -- [ ] **Step 3: Create `logging.rs`** - -```rust -//! Logging initialization. - -use perima_core::CoreError; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - -/// Init `tracing-subscriber`. Reads `PERIMA_LOG` (env filter, -/// default "info"); `PERIMA_LOG_JSON=1` for JSON output (else -/// human-readable text). Writes to stderr. `verbosity_bump` comes -/// from CLI `-v` count. -/// -/// # Errors -/// Returns `CoreError::Internal` if the global subscriber is already -/// set (tests should tolerate this). -pub fn init(verbosity_bump: u8) -> Result<(), CoreError> { - let base = std::env::var("PERIMA_LOG").unwrap_or_else(|_| "info".into()); - let bump_level = match verbosity_bump { - 0 => None, - 1 => Some("debug"), - _ => Some("trace"), - }; - let filter_str = match bump_level { - Some(lvl) => format!("{base},perima={lvl}"), - None => base, - }; - let filter = EnvFilter::try_new(&filter_str) - .map_err(|e| CoreError::Internal(format!("env filter: {e}")))?; - - let json = std::env::var("PERIMA_LOG_JSON") - .map(|v| v == "1") - .unwrap_or(false); - - let registry = tracing_subscriber::registry().with(filter); - if json { - registry - .with(fmt::layer().json().with_writer(std::io::stderr)) - .try_init() - .map_err(|e| CoreError::Internal(format!("subscriber: {e}"))) - } else { - registry - .with(fmt::layer().with_writer(std::io::stderr)) - .try_init() - .map_err(|e| CoreError::Internal(format!("subscriber: {e}"))) - } -} -``` - -- [ ] **Step 4: Run gates** - -Run: `cargo test -p perima --lib` → passes (at least `device_id_persists_across_calls`). -Run: `just ci` → exit 0. - ---- - -## Task 13: CLI — `signals` + `panic` modules - -**Files:** -- Create: `crates/cli/src/signals.rs` -- Create: `crates/cli/src/panic.rs` - -- [ ] **Step 1: Create `signals.rs`** - -```rust -//! Ctrl-C / SIGTERM handling. - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use perima_core::CoreError; - -/// Cancellation guard. Drop it to uninstall the signal handler -/// (so later tests or embedded usage can re-install). -/// -/// WHY guard semantics: `ctrlc::set_handler` is a process-global -/// singleton; without this guard pattern a test suite would fail -/// the second test that tried to install a handler. -pub struct Cancellation { - flag: Arc, -} - -impl Cancellation { - /// Has a cancellation signal been received? - #[must_use] - pub fn cancelled(&self) -> bool { - self.flag.load(Ordering::SeqCst) - } - - /// Share the cancellation flag with a worker closure (e.g. - /// rayon's `par_iter` map). WHY: we only expose the `Arc` for - /// this reason; callers that only need a bool should use - /// `cancelled()` instead. - #[must_use] - pub fn token(&self) -> Arc { - self.flag.clone() - } -} - -/// Install a process-global SIGINT/SIGTERM handler that flips the -/// cancellation flag. The returned guard holds the flag; keep it -/// alive for the duration you care about signals. -/// -/// WHY: on Ctrl-C we flip the flag rather than `std::process::exit` -/// so the scan loop can finish printing already-hashed entries — -/// stdout lines are per-file, and aborting mid-write would truncate. -/// -/// # Errors -/// Returns `CoreError::Internal` if another handler is already -/// registered (only one handler per process). -pub fn install() -> Result { - let flag = Arc::new(AtomicBool::new(false)); - let cloned = flag.clone(); - ctrlc::set_handler(move || { - cloned.store(true, Ordering::SeqCst); - }) - .map_err(|e| CoreError::Internal(format!("ctrlc: {e}")))?; - Ok(Cancellation { flag }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cancellation_flag_starts_false() { - let flag = Arc::new(AtomicBool::new(false)); - let c = Cancellation { flag: flag.clone() }; - assert!(!c.cancelled()); - flag.store(true, Ordering::SeqCst); - assert!(c.cancelled()); - } -} -``` - -- [ ] **Step 2: Create `panic.rs`** - -```rust -//! Panic handler routing panics through tracing. - -/// Install a panic hook that routes panics through -/// `tracing::error!` with thread info. Replaces the default -/// "thread 'xxx' panicked at …" so background rayon threads don't -/// die silently. -pub fn install() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let thread = std::thread::current(); - let name = thread.name().unwrap_or(""); - let location = info - .location() - .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) - .unwrap_or_else(|| "".into()); - tracing::error!( - thread = name, - location = %location, - payload = ?info.payload(), - "panic" - ); - // WHY: delegate to the default hook after logging so that - // the user still sees the standard backtrace on the tty. - default_hook(info); - })); -} -``` - -- [ ] **Step 3: Run gates** - -Run: `cargo test -p perima` → passes. -Run: `just ci` → exit 0. - ---- - -## Task 14: CLI — `cmd/scan.rs` - -**Files:** -- Create: `crates/cli/src/cmd/mod.rs` -- Create: `crates/cli/src/cmd/scan.rs` - -- [ ] **Step 1: Create `cmd/mod.rs`** - -```rust -//! CLI subcommand modules. - -pub mod scan; -``` - -- [ ] **Step 2: Create `cmd/scan.rs`** - -```rust -//! `perima scan --dry-run` implementation. - -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; - -use perima_core::{CoreError, DiscoveredFile, HashService, Scanner}; -use rayon::prelude::*; - -use crate::signals::Cancellation; - -/// Arguments for the scan command. -#[derive(Debug, Clone)] -pub struct ScanArgs { - /// Root directory to walk. - pub root: PathBuf, - /// When true, hashes and prints but skips all DB writes. - /// REQUIRED in phase 1a (no DB wired); optional in phase 1b. - pub dry_run: bool, - /// Suppress per-file stdout lines; print summary only. - pub quiet: bool, -} - -/// Exit code returned to `main`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExitCode { - /// Completed normally. - Success, - /// Ctrl-C received; partial scan summarized. - Interrupted, -} - -/// Execute `scan`. In 1a, `args.dry_run` must be `true`; the -/// caller (`main.rs`) enforces this and the scan's own guard -/// doubles as documentation. -/// -/// # Errors -/// Returns `CoreError::InvalidPath` if `root` is not a directory; -/// propagates `CoreError` from hashing and walking. -pub fn run( - scanner: &S, - hasher: &H, - cancel: &Cancellation, - args: &ScanArgs, -) -> Result -where - S: Scanner + ?Sized, - H: HashService + ?Sized, -{ - // WHY: guard fires BEFORE Config::resolve in main.rs, so the - // caller already rejected this path. If we still land here (e.g. - // programmatic call), surface Unsupported so the CLI maps to - // exit 2 without string-matching prose. - if !args.dry_run { - return Err(CoreError::Unsupported( - "phase 1a ships only 'scan --dry-run'; real scan arrives in 1b".into(), - )); - } - validate_root(&args.root)?; - - let volume_root = canonicalize_for_walk(&args.root)?; - let mut count: u64 = 0; - let stdout = std::io::stdout(); - - // Collect up-front so rayon can parallelize hashing; the walker - // iterator itself isn't Send across the par_iter boundary. The - // inner `take_while` polls between yielded items so a Ctrl-C - // during walk short-circuits quickly. - let discovered: Vec = scanner - .walk(&args.root, &volume_root)? - .take_while(|_| !cancel.cancelled()) - .collect(); - - // Parallel hash. WHY: we also check cancellation at the top of - // each map closure so in-flight hashes short-circuit the moment - // Ctrl-C lands — without this, a large fixture would drain the - // par_iter to completion even after the flag flips, defeating - // the "Ctrl-C stops hashing" guarantee in the spec. - let cancel_flag = cancel.token(); - let hashed: Vec> = discovered - .into_par_iter() - .map(|d| { - if cancel_flag.load(Ordering::SeqCst) { - return Err(CoreError::Internal("cancelled".into())); - } - let h = hasher.full_hash(&d.absolute_path)?; - Ok((d, h)) - }) - .collect(); - - let mut handle = stdout.lock(); - for res in hashed { - match res { - Ok((d, h)) => { - count += 1; - if !args.quiet { - writeln!( - handle, - "{} {} {}", - h.to_hex(), - d.size.0, - d.relative_path.as_str() - ) - .map_err(CoreError::Io)?; - } - } - Err(e) => { - tracing::warn!(error = %e, "skipping file: hash failed"); - } - } - } - drop(handle); - - let interrupted = cancel.cancelled(); - let suffix = if interrupted { " (interrupted)" } else { "" }; - eprintln!("scanned {count} files (dry-run; DB not yet wired){suffix}"); - - Ok(if interrupted { ExitCode::Interrupted } else { ExitCode::Success }) -} - -fn validate_root(root: &Path) -> Result<(), CoreError> { - if !root.exists() { - return Err(CoreError::InvalidPath(format!( - "does not exist: {}", - root.display() - ))); - } - if !root.is_dir() { - return Err(CoreError::InvalidPath(format!( - "not a directory: {}", - root.display() - ))); - } - Ok(()) -} - -fn canonicalize_for_walk(root: &Path) -> Result { - // dunce::canonicalize avoids UNC prefixes on Windows. - dunce::canonicalize(root).map_err(CoreError::Io) -} -``` - -- [ ] **Step 3: Run gates** - -Run: `just ci` → exit 0 (no new tests; compile check only). - ---- - -## Task 15: CLI — `main.rs` - -**Files:** -- Modify: `crates/cli/src/main.rs` - -- [ ] **Step 1: Full rewrite** - -```rust -//! `perima` command-line entry point. - -mod cmd; -mod config; -mod logging; -mod panic; -mod signals; - -use std::path::PathBuf; -use std::process::ExitCode; - -use clap::{Parser, Subcommand}; -use perima_fs::WalkdirScanner; -use perima_hash::Blake3Service; - -/// Cross-platform media asset manager. -#[derive(Parser, Debug)] -#[command( - name = "perima", - version, - about = "Index your media across drives by content hash" -)] -struct Cli { - /// Bump tracing verbosity; repeatable (-v, -vv). - #[arg(short, long, action = clap::ArgAction::Count, global = true)] - verbose: u8, - - /// Override the main database directory. Not used in phase 1a - /// (DB lands in 1b); accepted for forward compatibility. - #[arg(long, global = true)] - data_dir: Option, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Walk a directory, hash every file, and print the results. - Scan { - /// Directory to walk. - root: PathBuf, - - /// Required in phase 1a (clap enforces); 1b makes optional. - #[arg(long, required = true)] - dry_run: bool, - - /// Suppress per-file stdout lines. - #[arg(long)] - quiet: bool, - }, -} - -fn main() -> ExitCode { - panic::install(); - let cli = Cli::parse(); - - if let Err(e) = logging::init(cli.verbose) { - eprintln!("perima: logging init failed: {e}"); - return ExitCode::from(1); - } - - let cancel = match signals::install() { - Ok(c) => c, - Err(e) => { - eprintln!("perima: signal handler install failed: {e}"); - return ExitCode::from(1); - } - }; - - let _config = match config::Config::resolve(cli.data_dir.clone()) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: config resolution failed: {e}"); - return ExitCode::from(1); - } - }; - - match cli.command { - Command::Scan { root, dry_run, quiet } => { - let args = cmd::scan::ScanArgs { root, dry_run, quiet }; - let scanner = WalkdirScanner::new(); - let hasher = Blake3Service::new(); - match cmd::scan::run(&scanner, &hasher, &cancel, &args) { - Ok(cmd::scan::ExitCode::Success) => ExitCode::from(0), - Ok(cmd::scan::ExitCode::Interrupted) => ExitCode::from(130), - Err(perima_core::CoreError::InvalidPath(msg)) => { - eprintln!("perima: {msg}"); - ExitCode::from(2) - } - Err(perima_core::CoreError::Unsupported(msg)) => { - eprintln!("perima: {msg}"); - ExitCode::from(2) - } - Err(e) => { - eprintln!("perima: {e}"); - ExitCode::from(1) - } - } - } - } -} -``` - -- [ ] **Step 2: Run gates** - -Run: `just ci` → exit 0. -Run: `cargo run -p perima -- --help` → prints clap-generated help. -Run: `cargo run -p perima -- scan /tmp` → prints `perima: phase 1a ships only 'scan --dry-run'; real scan arrives in 1b`, exit 2. - -- [ ] **Step 3: Dispatch reviewer (checkpoint #3 — commit 3 prep)** - -Reviewer checklist: -- [ ] `perima --help` shows `scan` subcommand with `--dry-run`, `--quiet`, `--data-dir`, `-v/-vv`. -- [ ] `perima scan --dry-run` prints hash/size/path lines to stdout and a summary to stderr, exit 0. -- [ ] `perima scan ` (no `--dry-run`) exits 2 with the phase-1a guard message. -- [ ] Ctrl-C handler installed; `signals::Cancellation::cancelled()` unit test passes. -- [ ] Panic hook installed in `main`. -- [ ] `just ci` green. -- [ ] `// WHY:` comments in code match the four required bullets from the spec. - -Must return APPROVED before Step 4. - -- [ ] **Step 4: Commit** - -```bash -git add crates/cli/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-1a): CLI scaffold with perima scan --dry-run - -crates/cli gains: clap-derive root with -v/-vv/global data-dir, -single 'scan' subcommand (root, --dry-run required in 1a, --quiet). -Composition root wires WalkdirScanner + Blake3Service. - -Cross-cutting concerns land here to avoid retrofits in 1b+: -- config.rs: directories crate + env overrides + persistent - device_id.txt at /device_id.txt -- logging.rs: tracing-subscriber with PERIMA_LOG env filter, - PERIMA_LOG_JSON=1 for JSON on stderr, -v/-vv bumps -- signals.rs: ctrlc-based SIGINT/SIGTERM flag with guard semantics - so tests can re-install -- panic.rs: tracing::error! panic hook that delegates to the - default for tty backtraces - -Rayon parallelizes hashing across files; Ctrl-C flips an AtomicBool -polled between walk and hash so already-hashed lines finish -printing before the summary fires. - -Refs: docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 16: Property tests - -**Files:** -- Create: `crates/core/tests/props_hash_determinism.rs` -- Create: `crates/core/tests/props_path_idempotence.rs` -- Create: `crates/core/tests/props_path_nfc_equivalence.rs` -- Modify: `crates/core/Cargo.toml` - -- [ ] **Step 1: Add dev-deps** - -Append to `crates/core/Cargo.toml`: - -```toml -[dev-dependencies] -proptest.workspace = true -``` - -- [ ] **Step 2: Create `props_hash_determinism.rs`** - -```rust -//! Property: the same byte input always produces the same -//! `BlakeHash`. - -use proptest::prelude::*; - -proptest! { - #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] - #[test] - fn blake_hash_is_deterministic(bytes in proptest::collection::vec(any::(), 0..2048)) { - let a = blake3::hash(&bytes); - let b = blake3::hash(&bytes); - prop_assert_eq!(a.as_bytes(), b.as_bytes()); - } -} -``` - -- [ ] **Step 3: Create `props_path_idempotence.rs`** - -```rust -//! Property: `MediaPath::new` is idempotent under repeated -//! application — `f(f(s)) == f(s)` for every input. - -use perima_core::MediaPath; -use proptest::prelude::*; - -proptest! { - #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] - #[test] - fn media_path_is_idempotent(s in "\\PC{0,200}") { - let once = MediaPath::new(&s); - let twice = MediaPath::new(once.as_str()); - prop_assert_eq!(once, twice); - } -} -``` - -- [ ] **Step 4: Create `props_path_nfc_equivalence.rs`** - -```rust -//! Property: `MediaPath::new` collapses canonically-equivalent -//! inputs. Applying NFD decomposition before construction must -//! yield the same `MediaPath`. - -use perima_core::MediaPath; -use proptest::prelude::*; -use unicode_normalization::UnicodeNormalization; - -proptest! { - #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] - #[test] - fn media_path_nfc_equivalence(s in "\\PC{0,200}") { - let nfd: String = s.nfd().collect(); - let from_original = MediaPath::new(&s); - let from_nfd = MediaPath::new(&nfd); - prop_assert_eq!(from_original, from_nfd); - } -} -``` - -The `unicode-normalization` dep is already visible to tests via -the regular `[dependencies]` entry from Task 2 — do NOT re-declare -it under `[dev-dependencies]`. - -- [ ] **Step 5: Run gates** - -Run: `cargo test -p perima-core --tests` → 3 proptests pass (256 cases each). -Run: `just ci` → exit 0. - ---- - -## Task 17: Integration test - -**Files:** -- Create: `crates/cli/tests/scan_dry_run.rs` - -- [ ] **Step 1: Write the integration test** - -```rust -//! End-to-end `perima scan --dry-run` via the built binary. - -use std::io::Write; -use std::path::Path; -use std::process::Command; - -fn mk_fixture(dir: &Path) -> Vec<(String, Vec)> { - let mut files = vec![ - ("alpha.txt".to_string(), b"alpha".to_vec()), - ("sub/beta.txt".to_string(), b"beta".to_vec()), - ("sub/gamma.bin".to_string(), b"\x00\x01\x02\x03".to_vec()), - ]; - for (name, bytes) in &files { - let path = dir.join(name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("mkdir"); - } - std::fs::File::create(&path) - .expect("create") - .write_all(bytes) - .expect("write"); - } - files.sort(); - files -} - -fn bin() -> &'static str { - env!("CARGO_BIN_EXE_perima") -} - -#[test] -fn dry_run_prints_hashes_and_summary() { - let td = tempfile::tempdir().expect("tempdir"); - let _fixture = mk_fixture(td.path()); - - let tmp_env = tempfile::tempdir().expect("env dir"); - let output = Command::new(bin()) - .arg("scan") - .arg("--dry-run") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", tmp_env.path()) - .env("PERIMA_DATA_DIR", tmp_env.path()) - .output() - .expect("run perima"); - - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - - let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - let mut lines: Vec<&str> = stdout.lines().collect(); - lines.sort(); - assert_eq!(lines.len(), 3, "expected 3 hashed files, got: {lines:?}"); - - for line in &lines { - let mut parts = line.splitn(3, " "); - let hash = parts.next().expect("hash field"); - let size = parts.next().expect("size field"); - let path = parts.next().expect("path field"); - assert_eq!(hash.len(), 64, "bad hash length in: {line}"); - assert!( - hash.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), - "hash not lowercase hex: {line}" - ); - assert!(size.bytes().all(|b| b.is_ascii_digit()), "bad size: {line}"); - assert!(!path.is_empty(), "empty path: {line}"); - } - - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!( - stderr.contains("scanned 3 files (dry-run; DB not yet wired)"), - "stderr missing summary; got: {stderr}" - ); -} - -#[test] -fn dry_run_is_deterministic_across_runs() { - let td = tempfile::tempdir().expect("tempdir"); - mk_fixture(td.path()); - let tmp_env = tempfile::tempdir().expect("env dir"); - - let run = || { - let output = Command::new(bin()) - .arg("scan") - .arg("--dry-run") - .arg("--quiet") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", tmp_env.path()) - .env("PERIMA_DATA_DIR", tmp_env.path()) - .output() - .expect("run perima"); - assert!(output.status.success()); - String::from_utf8(output.stdout).expect("utf8") - }; - - // With --quiet, stdout is empty on both runs. - let a = run(); - let b = run(); - assert_eq!(a, b); -} - -#[test] -fn real_scan_refused_in_phase_1a() { - let td = tempfile::tempdir().expect("tempdir"); - mk_fixture(td.path()); - let tmp_env = tempfile::tempdir().expect("env dir"); - - let output = Command::new(bin()) - .arg("scan") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", tmp_env.path()) - .env("PERIMA_DATA_DIR", tmp_env.path()) - .output() - .expect("run perima"); - - assert_eq!(output.status.code(), Some(2), "expected exit 2"); - let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - assert!( - stderr.contains("phase 1a ships only 'scan --dry-run'"), - "stderr missing guard message; got: {stderr}" - ); -} -``` - -- [ ] **Step 2: No extra dev-deps** - -The integration test uses only `std` + `tempfile` (already in -`crates/cli/Cargo.toml` dev-deps). No regex dep needed. - -- [ ] **Step 3: Run gates** - -Run: `cargo test -p perima --tests` → 3 integration tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 18: Final sweep + WHY grep + commit + tag - -- [ ] **Step 1: Grep WHY comments** - -Run: -```bash -grep -rE '^\s*//\s*WHY:' crates/ | tee /tmp/why.txt -wc -l /tmp/why.txt -``` -Expected: **at least 4 lines**. Covers: `MediaPath::new`, `HashService: Send+Sync`, Ctrl-C AtomicBool, content-PK (blake3_hash — documented in spec, noted in-code via the core types module docstring). - -If fewer than 4, add the missing WHY comments to match the spec's "WHY-comments required in code" section. - -- [ ] **Step 2: `just ci` clean sweep** - -Run: `cargo clean && just ci` -Expected: exit 0 on fresh build. - -- [ ] **Step 3: Dispatch final reviewer (checkpoint #4)** - -Reviewer checklist: -- [ ] All property tests pass (256 cases each). -- [ ] All integration tests pass on Linux (Unix-gated Ctrl-C test is absent or passes). -- [ ] `grep -rE '^\s*//\s*WHY:' crates/ | wc -l` ≥ 4. -- [ ] No `.md` file appears in `git ls-files '*.md'`. -- [ ] Clippy clean with `-D warnings` including the new code. -- [ ] Docs: `cargo doc --workspace --no-deps` produces no missing-doc errors. -- [ ] Every phase 1a exit criterion from the spec is observably met. - -Must return APPROVED before Step 4. - -- [ ] **Step 4: Commit tests + tag phase** - -```bash -git add crates/core/tests/ crates/core/Cargo.toml crates/cli/tests/ crates/cli/Cargo.toml Cargo.lock -git commit -m "$(cat <<'EOF' -test(phase-1a): property + integration tests for scan --dry-run - -- proptests (256 cases each) in crates/core/tests/: - * hash determinism (blake3::hash) - * MediaPath idempotence (f(f(s)) == f(s)) - * MediaPath NFC equivalence (NFC input == NFD input) -- crates/cli/tests/scan_dry_run.rs exercises the built binary: - * 3 fixture files produce 3 hash/size/path lines - * --quiet+determinism: two runs match byte-for-byte - * real scan (no --dry-run) refused with exit 2 - -Refs: docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Push main and wait for CI green BEFORE tagging** - -```bash -git push origin main -gh run watch --exit-status \ - "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` -Expected: green across all 3 matrix jobs. - -If red: `gh run view --log-failed` and open a bugfix task. -Do NOT tag a red commit. Do NOT mask with `continue-on-error`. - -- [ ] **Step 6: Tag phase boundary (ONLY after CI is green)** - -```bash -git tag -a phase-1a-complete -m "Phase 1a: core types + hash + fs + CLI scan --dry-run" -git push origin phase-1a-complete -``` - ---- - -## Self-review - -**Spec coverage:** -- Domain types (BlakeHash, FileSize, MediaPath, VolumeId, DeviceId, DiscoveredFile, HashedFile, LocationStatus, UpsertOutcome, FileLocationRecord, VolumeIdentifiers, VolumeRecord) → Tasks 5, 6, 7. -- Trait ports (HashService, Scanner, FileRepository, VolumeRepository) → Task 8. -- Errors (CoreError, adapter Error types + From conversions) → Tasks 4, 9, 10. -- Hash adapter (Blake3Service with two-phase) → Task 9. -- FS adapter (relativize, WalkdirScanner) → Tasks 10, 11. -- CLI cross-cutting (config, logging, signals, panic) → Tasks 12, 13. -- `perima scan --dry-run` → Tasks 14, 15. -- Property tests (hash det, path idempotence, NFC equiv) → Task 16. -- Integration tests → Task 17. -- WHY comments (4 required) → checked in Task 18 Step 1. -- Exit criteria 1–9 → Task 18 verification. - -**Placeholder scan:** no TBD/TODO/"implement later". Every file has exact content. - -**Type consistency:** -- `BlakeHash`, `MediaPath`, `VolumeId`, `DeviceId`, `FileSize` match between types.rs, ports, adapters, CLI. -- `FileLocationRecord` (not `FileRecord`) used consistently in ports and later consumers. -- `DiscoveredFile.absolute_path` / `.relative_path` / `.size` match between scanner impl and consumers. -- Signal guard name `Cancellation` consistent between `signals.rs` and `scan.rs`. -- `ScanArgs` / `ExitCode` / `run()` match between `cmd/scan.rs` and `main.rs`. - -**Commit discipline:** four commits gated by four reviewer checkpoints, matching CLAUDE.md "execute → tests → reviewer → commit." Push + tag happen only at the final checkpoint. diff --git a/docs/superpowers/plans/2026-04-16-phase-1b-db-ls.md b/docs/superpowers/plans/2026-04-16-phase-1b-db-ls.md deleted file mode 100644 index d1203ee..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-1b-db-ls.md +++ /dev/null @@ -1,1695 +0,0 @@ -# Phase 1b — DB Adapter + `perima ls` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire `crates/db` as a rusqlite adapter implementing `FileRepository`. `perima scan ` persists files + locations to SQLite. `perima ls` reads and prints them. The phase-1a `--dry-run` guard is removed; `--dry-run` becomes optional (default off). - -**Architecture:** `crates/db` gets: `errors.rs` (rich `From for CoreError`), `connection.rs` (WAL + synchronous=NORMAL pragmas), `migrations/V001__initial.sql` (CRDT-ready schema), `file_repo.rs` (`SqliteFileRepository` impls `FileRepository`). `crates/cli` gets: `cmd/ls.rs` (new), `cmd/scan.rs` (drop phase-1a guard, add DB persist path), `main.rs` (add `Ls` command variant). Sentinel `VolumeId(Uuid::nil())` used until 1c wires real volume detection. - -**Tech Stack:** rusqlite 0.38 (bundled), refinery 0.9, chrono 0.4, uuid v7, thiserror, tracing. Existing: blake3, walkdir, clap 4, rayon, ctrlc, tempfile, insta. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md` - -**Execution rule:** All work on `main`. Per-commit: execute → `just ci` green → reviewer approves → commit. No branches, no `--force`. - ---- - -## File Structure - -**New/modified in this phase:** - -``` -Cargo.toml # modify — add chrono workspace dep -crates/db/ -├── Cargo.toml # modify — add deps -├── migrations/ -│ └── V001__initial.sql # new — CRDT-ready schema -└── src/ - ├── lib.rs # modify — re-exports - ├── errors.rs # new - ├── connection.rs # new - └── file_repo.rs # new - -crates/cli/ -├── Cargo.toml # modify — add perima-db + chrono -├── src/ -│ ├── main.rs # modify — add Ls command -│ └── cmd/ -│ ├── mod.rs # modify — add ls module -│ ├── scan.rs # modify — remove guard, add DB path -│ └── ls.rs # new -└── tests/ - ├── scan_dry_run.rs # modify — update tests - ├── scan_persists.rs # new - └── ls_output.rs # new -``` - -Three reviewer-gated commits: (1) db crate, (2) CLI changes, (3) integration tests. - ---- - -## Task 1: Workspace + crate dependency setup - -**Files:** -- Modify: `Cargo.toml` (workspace deps) -- Modify: `crates/db/Cargo.toml` -- Modify: `crates/cli/Cargo.toml` - -- [ ] **Step 1: Add `chrono` to workspace dependencies** - -In root `Cargo.toml`, add to `[workspace.dependencies]` before the `[profile.release]` section: - -```toml -chrono = { version = "0.4", features = ["serde"] } -``` - -- [ ] **Step 2: Update `crates/db/Cargo.toml`** - -Replace contents with: - -```toml -[package] -name = "perima-db" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -perima-core = { path = "../core" } -rusqlite.workspace = true -refinery.workspace = true -thiserror.workspace = true -tracing.workspace = true -uuid.workspace = true -chrono.workspace = true - -[dev-dependencies] -tempfile.workspace = true - -[lints] -workspace = true -``` - -- [ ] **Step 3: Add `perima-db` + `chrono` to `crates/cli/Cargo.toml`** - -Add after the `perima-fs` line in `[dependencies]`: - -```toml -perima-db = { path = "../db" } -chrono.workspace = true -``` - -- [ ] **Step 4: Verify resolution** - -Run: `cargo metadata --format-version 1 >/dev/null && echo ok` -Expected: `ok`. - ---- - -## Task 2: DB errors (`crates/db/src/errors.rs`) - -**Files:** -- Create: `crates/db/src/errors.rs` - -- [ ] **Step 1: Write the module** - -```rust -//! Internal errors for the database adapter. - -use thiserror::Error; - -/// Errors raised inside `perima-db`. -#[derive(Debug, Error)] -pub enum Error { - /// Low-level `rusqlite` failure. - #[error("rusqlite: {0}")] - Rusqlite(#[from] rusqlite::Error), - - /// Migration failure via `refinery`. - #[error("migration: {0}")] - Refinery(String), - - /// Application-level uniqueness violation (no DB UNIQUE constraint; - /// enforced in code per CLAUDE.md CRDT rules). - #[error("app-level duplicate: {0}")] - AppLevelDuplicate(String), -} - -impl From for perima_core::CoreError { - fn from(e: Error) -> Self { - match &e { - Error::Rusqlite(inner) => match inner { - rusqlite::Error::QueryReturnedNoRows => Self::NotFound(e.to_string()), - rusqlite::Error::SqliteFailure(f, _) - if f.code == rusqlite::ErrorCode::ConstraintViolation => - { - Self::Duplicate(e.to_string()) - } - _ => Self::Internal(e.to_string()), - }, - Error::AppLevelDuplicate(_) => Self::Duplicate(e.to_string()), - Error::Refinery(_) => Self::Internal(e.to_string()), - } - } -} -``` - ---- - -## Task 3: DB connection + migrations - -**Files:** -- Create: `crates/db/src/connection.rs` -- Create: `crates/db/migrations/V001__initial.sql` - -- [ ] **Step 1: Create `connection.rs`** - -```rust -//! Database connection factory with production pragmas. - -use std::path::Path; - -use rusqlite::Connection; - -use crate::errors::Error; - -mod embedded { - use refinery::embed_migrations; - embed_migrations!("migrations"); -} - -/// Open (or create) the main database at `path`, apply pragmas, -/// and run pending migrations. -/// -/// WHY WAL: Write-Ahead Logging allows concurrent reads during a -/// scan write transaction. Without it, SQLite's default rollback -/// journal serializes all access, making `perima ls` block while -/// `perima scan` is running. -/// -/// WHY synchronous=NORMAL: under WAL, NORMAL is safe against data -/// loss on process crash (only OS crash can lose the last txn). -/// FULL would fsync every commit — measurably slower on 100k-file -/// scans and unnecessary for a local index rebuildable from source. -/// -/// # Errors -/// Returns `Error::Rusqlite` on connection/pragma failure, or -/// `Error::Refinery` on migration failure. -pub fn open_and_migrate(path: &Path) -> Result { - let mut conn = Connection::open(path)?; - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - PRAGMA foreign_keys = OFF;", - )?; - embedded::migrations::runner() - .run(&mut conn) - .map_err(|e| Error::Refinery(e.to_string()))?; - Ok(conn) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn open_sets_wal_mode() { - let td = tempfile::tempdir().expect("tempdir"); - let conn = open_and_migrate(&td.path().join("test.db")).expect("open"); - let mode: String = conn - .query_row("PRAGMA journal_mode", [], |r| r.get(0)) - .expect("pragma"); - assert_eq!(mode, "wal"); - } - - #[test] - fn migrations_are_idempotent() { - let td = tempfile::tempdir().expect("tempdir"); - let db_path = td.path().join("test.db"); - let _conn1 = open_and_migrate(&db_path).expect("first open"); - drop(_conn1); - let _conn2 = open_and_migrate(&db_path).expect("second open"); - } -} -``` - -- [ ] **Step 2: Create migration SQL** - -Create directory: `mkdir -p crates/db/migrations` - -Create `crates/db/migrations/V001__initial.sql`: - -```sql --- WHY: blake3_hash is the PK on files because a BLAKE3-256 hash is --- deterministic and content-derived — two devices hashing identical --- bytes MUST compute the same value, making it CRDT-merge-safe --- (effectively a deterministic UUID). The UUIDv7 rule applies only --- to rows whose identity is NOT content-derived. -CREATE TABLE files ( - blake3_hash TEXT PRIMARY KEY, - file_size INTEGER NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE TABLE file_locations ( - id TEXT PRIMARY KEY, - blake3_hash TEXT NOT NULL, - volume_id TEXT NOT NULL, - relative_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - last_verified TEXT, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE INDEX idx_file_locations_blake3 - ON file_locations(blake3_hash); -CREATE INDEX idx_file_locations_volume_path - ON file_locations(volume_id, relative_path); - -CREATE TABLE volumes ( - volume_id TEXT PRIMARY KEY, - gpt_partition_guid TEXT, - fs_uuid TEXT, - volume_label TEXT, - capacity_bytes INTEGER NOT NULL, - is_removable INTEGER NOT NULL, - last_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE TABLE volume_mounts ( - id TEXT PRIMARY KEY, - volume_id TEXT NOT NULL, - machine_id TEXT NOT NULL, - mount_path TEXT NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE INDEX idx_volume_mounts_volume_machine - ON volume_mounts(volume_id, machine_id); -``` - -- [ ] **Step 3: Run the gates** - -Run: `cargo build -p perima-db` -Expected: exit 0. (refinery embeds the migration at compile time.) - -Run: `cargo test -p perima-db` -Expected: 2 tests pass (open_sets_wal_mode, migrations_are_idempotent). - ---- - -## Task 4: `SqliteFileRepository` (`crates/db/src/file_repo.rs`) — TDD - -**Files:** -- Create: `crates/db/src/file_repo.rs` -- Modify: `crates/db/src/lib.rs` - -- [ ] **Step 1: Write tests first** - -Create `crates/db/src/file_repo.rs` with the test module: - -```rust -//! `FileRepository` implementation backed by rusqlite. - -use std::path::PathBuf; - -use perima_core::{ - BlakeHash, CoreError, DeviceId, FileLocationRecord, FileRepository, FileSize, HashedFile, - LocationStatus, MediaPath, UpsertOutcome, VolumeId, -}; -use rusqlite::Connection; - -use crate::errors::Error; - -/// Rusqlite-backed file + location repository. -pub struct SqliteFileRepository<'conn> { - conn: &'conn Connection, -} - -impl<'conn> SqliteFileRepository<'conn> { - /// Wrap an existing connection. The caller must have run - /// migrations before constructing this. - pub fn new(conn: &'conn Connection) -> Self { - Self { conn } - } -} - -fn now_iso() -> String { - chrono::Utc::now().to_rfc3339() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::connection::open_and_migrate; - - fn test_db() -> (tempfile::TempDir, Connection) { - let td = tempfile::tempdir().expect("tempdir"); - let conn = open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, conn) - } - - fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile { - let hash = BlakeHash::from_bytes(*blake3::hash(content).as_bytes()); - HashedFile { - discovered: perima_core::DiscoveredFile { - absolute_path: PathBuf::from("/tmp/fake"), - relative_path: MediaPath::new(rel_path), - size: FileSize(content.len() as u64), - }, - hash, - } - } - - fn device() -> DeviceId { - DeviceId::new() - } - - fn sentinel_volume() -> VolumeId { - VolumeId(uuid::Uuid::nil()) - } - - #[test] - fn upsert_file_inserts_new() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let f = sample_hashed_file(b"hello", "a.txt"); - let out = repo.upsert_file(&f, device()).expect("upsert"); - assert_eq!(out, UpsertOutcome::Inserted); - } - - #[test] - fn upsert_file_unchanged_on_repeat() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let f = sample_hashed_file(b"hello", "a.txt"); - repo.upsert_file(&f, dev).expect("first"); - let out = repo.upsert_file(&f, dev).expect("second"); - assert_eq!(out, UpsertOutcome::Unchanged); - } - - #[test] - fn upsert_file_updated_on_size_change() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let f1 = sample_hashed_file(b"hello", "a.txt"); - repo.upsert_file(&f1, dev).expect("first"); - // Same hash, different size (contrived but tests the branch). - let mut f2 = f1.clone(); - f2.discovered.size = FileSize(999); - let out = repo.upsert_file(&f2, dev).expect("second"); - assert_eq!(out, UpsertOutcome::Updated); - } - - #[test] - fn upsert_location_inserts_new() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let f = sample_hashed_file(b"hello", "a.txt"); - repo.upsert_file(&f, dev).expect("file"); - let out = repo - .upsert_location(&f.hash, sentinel_volume(), &f.discovered.relative_path, dev) - .expect("loc"); - assert_eq!(out, UpsertOutcome::Inserted); - } - - #[test] - fn upsert_location_unchanged_on_repeat() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let f = sample_hashed_file(b"hello", "a.txt"); - repo.upsert_file(&f, dev).expect("file"); - let vol = sentinel_volume(); - let path = &f.discovered.relative_path; - repo.upsert_location(&f.hash, vol, path, dev).expect("first"); - let out = repo - .upsert_location(&f.hash, vol, path, dev) - .expect("second"); - assert_eq!(out, UpsertOutcome::Unchanged); - } - - #[test] - fn upsert_location_updated_on_hash_change() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let f1 = sample_hashed_file(b"hello", "a.txt"); - let f2 = sample_hashed_file(b"world", "a.txt"); - repo.upsert_file(&f1, dev).expect("file1"); - repo.upsert_file(&f2, dev).expect("file2"); - let vol = sentinel_volume(); - let path = MediaPath::new("a.txt"); - repo.upsert_location(&f1.hash, vol, &path, dev) - .expect("first"); - let out = repo - .upsert_location(&f2.hash, vol, &path, dev) - .expect("second"); - assert_eq!(out, UpsertOutcome::Updated); - } - - #[test] - fn list_file_locations_returns_all() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let vol = sentinel_volume(); - for (i, name) in ["a.txt", "b.txt", "c.txt"].iter().enumerate() { - let f = sample_hashed_file(format!("content{i}").as_bytes(), name); - repo.upsert_file(&f, dev).expect("file"); - repo.upsert_location(&f.hash, vol, &f.discovered.relative_path, dev) - .expect("loc"); - } - let results = repo.list_file_locations(100, None).expect("list"); - assert_eq!(results.len(), 3); - // Ordered by relative_path. - assert_eq!(results[0].relative_path.as_str(), "a.txt"); - assert_eq!(results[2].relative_path.as_str(), "c.txt"); - } - - #[test] - fn list_file_locations_respects_limit() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let vol = sentinel_volume(); - for i in 0..5 { - let f = sample_hashed_file(format!("c{i}").as_bytes(), &format!("f{i}.txt")); - repo.upsert_file(&f, dev).expect("file"); - repo.upsert_location(&f.hash, vol, &f.discovered.relative_path, dev) - .expect("loc"); - } - let results = repo.list_file_locations(2, None).expect("list"); - assert_eq!(results.len(), 2); - } - - #[test] - fn list_file_locations_filters_by_volume() { - let (_td, conn) = test_db(); - let mut repo = SqliteFileRepository::new(&conn); - let dev = device(); - let vol_a = VolumeId::new(); - let vol_b = VolumeId::new(); - let f1 = sample_hashed_file(b"alpha", "a.txt"); - let f2 = sample_hashed_file(b"beta", "b.txt"); - repo.upsert_file(&f1, dev).expect("f1"); - repo.upsert_file(&f2, dev).expect("f2"); - repo.upsert_location(&f1.hash, vol_a, &f1.discovered.relative_path, dev) - .expect("loc1"); - repo.upsert_location(&f2.hash, vol_b, &f2.discovered.relative_path, dev) - .expect("loc2"); - let a_only = repo - .list_file_locations(100, Some(vol_a)) - .expect("list"); - assert_eq!(a_only.len(), 1); - assert_eq!(a_only[0].relative_path.as_str(), "a.txt"); - } -} -``` - -- [ ] **Step 2: Run — expect compile error (no impl yet)** - -Run: `cargo test -p perima-db --lib` -Expected: compile error — `SqliteFileRepository` doesn't implement `FileRepository`. - -- [ ] **Step 3: Implement `FileRepository` for `SqliteFileRepository`** - -Add to `crates/db/src/file_repo.rs` (above the `#[cfg(test)]` block): - -```rust -impl FileRepository for SqliteFileRepository<'_> { - fn upsert_file( - &mut self, - file: &HashedFile, - device: DeviceId, - ) -> Result { - let hash_hex = file.hash.to_hex(); - let now = now_iso(); - let dev_str = device.0.to_string(); - - // WHY: two-statement SELECT-then-INSERT/UPDATE because - // SQLite's changes() cannot distinguish a fresh INSERT from - // a conflict-triggered UPDATE — both report 1. - let existing: Option<(i64, String)> = self - .conn - .query_row( - "SELECT file_size, device_id FROM files WHERE blake3_hash = ?1", - [&hash_hex], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Error::from)?; - - match existing { - None => { - self.conn - .execute( - "INSERT INTO files (blake3_hash, file_size, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?3, ?4)", - rusqlite::params![hash_hex, file.discovered.size.0 as i64, now, dev_str], - ) - .map_err(Error::from)?; - Ok(UpsertOutcome::Inserted) - } - Some((existing_size, existing_dev)) - if existing_size == file.discovered.size.0 as i64 - && existing_dev == dev_str => - { - Ok(UpsertOutcome::Unchanged) - } - Some(_) => { - self.conn - .execute( - "UPDATE files SET file_size = ?1, updated_at = ?2, device_id = ?3 - WHERE blake3_hash = ?4", - rusqlite::params![ - file.discovered.size.0 as i64, - now, - dev_str, - hash_hex - ], - ) - .map_err(Error::from)?; - Ok(UpsertOutcome::Updated) - } - } - } - - fn upsert_location( - &mut self, - hash: &BlakeHash, - volume: VolumeId, - path: &MediaPath, - device: DeviceId, - ) -> Result { - let hash_hex = hash.to_hex(); - let vol_str = volume.0.to_string(); - let path_str = path.as_str(); - let dev_str = device.0.to_string(); - let now = now_iso(); - - // WHY: app-level uniqueness on (volume_id, relative_path, - // deleted_at IS NULL) replaces a UNIQUE constraint that - // CLAUDE.md forbids on mutable columns. The two-statement - // pattern is safe under SQLite's single-writer model. - let existing: Option<(String, String, String)> = self - .conn - .query_row( - "SELECT id, blake3_hash, device_id FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", - rusqlite::params![vol_str, path_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional() - .map_err(Error::from)?; - - match existing { - None => { - let id = perima_core::ids::new_id().to_string(); - self.conn - .execute( - "INSERT INTO file_locations - (id, blake3_hash, volume_id, relative_path, status, - first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6)", - rusqlite::params![id, hash_hex, vol_str, path_str, now, dev_str], - ) - .map_err(Error::from)?; - Ok(UpsertOutcome::Inserted) - } - Some((_, ref existing_hash, ref existing_dev)) - if *existing_hash == hash_hex && *existing_dev == dev_str => - { - Ok(UpsertOutcome::Unchanged) - } - Some((ref row_id, _, _)) => { - self.conn - .execute( - "UPDATE file_locations - SET blake3_hash = ?1, updated_at = ?2, device_id = ?3 - WHERE id = ?4", - rusqlite::params![hash_hex, now, dev_str, row_id], - ) - .map_err(Error::from)?; - Ok(UpsertOutcome::Updated) - } - } - } - - fn list_file_locations( - &self, - limit: usize, - volume: Option, - ) -> Result, CoreError> { - let vol_filter = volume.map(|v| v.0.to_string()); - let mut stmt = self - .conn - .prepare( - "SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, - fl.status, fl.first_seen - FROM file_locations fl - JOIN files f ON f.blake3_hash = fl.blake3_hash - WHERE fl.deleted_at IS NULL - AND (?1 IS NULL OR fl.volume_id = ?1) - ORDER BY fl.relative_path - LIMIT ?2", - ) - .map_err(Error::from)?; - - let rows = stmt - .query_map( - rusqlite::params![vol_filter, limit as i64], - |row| { - let hash_hex: String = row.get(0)?; - let size: i64 = row.get(1)?; - let vol_str: String = row.get(2)?; - let rel_path: String = row.get(3)?; - let status_str: String = row.get(4)?; - let first_seen: String = row.get(5)?; - Ok((hash_hex, size, vol_str, rel_path, status_str, first_seen)) - }, - ) - .map_err(Error::from)?; - - let mut out = Vec::new(); - for row in rows { - let (hash_hex, size, vol_str, rel_path, status_str, first_seen) = - row.map_err(Error::from)?; - let hash = BlakeHash::parse_hex(&hash_hex)?; - let volume_id = VolumeId( - uuid::Uuid::parse_str(&vol_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?, - ); - let status = match status_str.as_str() { - "active" => LocationStatus::Active, - "missing" => LocationStatus::Missing, - "moved" => LocationStatus::Moved, - other => { - return Err(CoreError::Internal(format!( - "unknown location status: {other}" - ))) - } - }; - out.push(FileLocationRecord { - hash, - size: FileSize(size as u64), - volume_id, - relative_path: MediaPath::new(&rel_path), - status, - first_seen, - }); - } - Ok(out) - } -} - -use rusqlite::OptionalExtension; -``` - -Note: `use rusqlite::OptionalExtension;` must be at the module level (not inside `impl`) for the `.optional()` call to work. Place it with the other imports at the top of the file. - -- [ ] **Step 4: Update `crates/db/src/lib.rs`** - -```rust -//! SQLite adapter for perima. - -pub mod connection; -pub mod errors; -pub mod file_repo; - -pub use connection::open_and_migrate; -pub use errors::Error; -pub use file_repo::SqliteFileRepository; -``` - -- [ ] **Step 5: Add `blake3` dev-dep to `crates/db/Cargo.toml`** - -The tests use `blake3::hash` to generate sample hashes. Add: - -```toml -blake3.workspace = true -``` - -under `[dev-dependencies]`. - -- [ ] **Step 6: Run the gates** - -Run: `cargo test -p perima-db` → expect 11 tests pass (2 connection + 9 file_repo). -Run: `just ci` → exit 0. - -- [ ] **Step 7: Dispatch reviewer (checkpoint #1 — db crate)** - -Reviewer checklist: -- [ ] Schema matches spec (CRDT rules, WHY comment on blake3_hash PK). -- [ ] WAL + synchronous=NORMAL pragmas applied, WHY comments present. -- [ ] `open_and_migrate` runs both pragmas and migrations. -- [ ] `upsert_file`: SELECT-then-INSERT/UPDATE, returns correct outcomes. -- [ ] `upsert_location`: app-level uniqueness, WHY comment, correct outcomes. -- [ ] `list_file_locations`: JOIN query, volume filter, limit, path ordering. -- [ ] `From for CoreError`: `QueryReturnedNoRows`→`NotFound`, constraint→`Duplicate`. -- [ ] 11 tests pass. `just ci` green. - -- [ ] **Step 8: Commit (after APPROVED)** - -```bash -git add Cargo.toml Cargo.lock crates/db/ -git commit -m "$(cat <<'EOF' -feat(phase-1b): rusqlite DB adapter with FileRepository - -crates/db wired with: connection.rs (WAL + synchronous=NORMAL -pragmas), V001__initial.sql (CRDT-ready schema — files, -file_locations, volumes, volume_mounts), SqliteFileRepository -implementing FileRepository with two-statement SELECT-then- -INSERT/UPDATE for upsert_file and upsert_location (app-level -uniqueness replaces UNIQUE constraint per CLAUDE.md CRDT rules). - -Rich error mapping: rusqlite QueryReturnedNoRows → CoreError:: -NotFound, constraint violations → Duplicate, refinery failures -→ Internal. - -11 unit tests: WAL pragma, idempotent migration, insert/unchanged/ -updated for both upsert_file and upsert_location, list with -ordering/limit/volume-filter. - -Refs: docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 5: CLI — update `scan.rs` to persist - -**Files:** -- Modify: `crates/cli/src/cmd/scan.rs` - -- [ ] **Step 1: Rewrite `scan.rs`** - -Replace the entire contents of `crates/cli/src/cmd/scan.rs` with: - -```rust -//! `perima scan` implementation. - -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; - -use perima_core::{ - BlakeHash, CoreError, DeviceId, DiscoveredFile, FileRepository, HashService, MediaPath, - Scanner, UpsertOutcome, VolumeId, -}; -use rayon::prelude::*; - -use crate::signals::Cancellation; - -/// Arguments for the scan command. -#[derive(Debug, Clone)] -pub struct ScanArgs { - /// Root directory to walk. - pub root: PathBuf, - /// When true, hashes and prints but skips all DB writes. - pub dry_run: bool, - /// Suppress per-file stdout lines; print summary only. - pub quiet: bool, -} - -/// Scan statistics. -#[derive(Debug, Clone, Copy, Default)] -pub struct ScanStats { - /// Files newly indexed. - pub new: u64, - /// Files already present (unchanged). - pub existing: u64, - /// Files that errored during hash or persist. - pub errors: u64, -} - -/// Exit code returned to `main`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExitCode { - /// Completed normally. - Success, - /// Ctrl-C received; partial scan summarized. - Interrupted, -} - -/// Execute `scan`. -/// -/// # Errors -/// Returns `CoreError::InvalidPath` if `root` is not a directory; -/// propagates `CoreError` from hashing and walking. -pub fn run( - scanner: &S, - hasher: &H, - repo: Option<&mut R>, - device: DeviceId, - volume: VolumeId, - cancel: &Cancellation, - args: &ScanArgs, -) -> Result<(ExitCode, ScanStats), CoreError> -where - S: Scanner + ?Sized, - H: HashService + ?Sized, - R: FileRepository + ?Sized, -{ - validate_root(&args.root)?; - - let canonical_root = canonicalize_for_walk(&args.root)?; - let volume_root = canonical_root.clone(); - let stdout = std::io::stdout(); - let mut stats = ScanStats::default(); - - let discovered: Vec = scanner - .walk(&canonical_root, &volume_root)? - .take_while(|_| !cancel.cancelled()) - .collect(); - - let cancel_flag = cancel.token(); - let results: Vec> = discovered - .into_par_iter() - .map(|d| { - if cancel_flag.load(Ordering::SeqCst) { - return Err(CoreError::Internal("cancelled".into())); - } - let h = hasher.full_hash(&d.absolute_path)?; - Ok((d, h)) - }) - .collect(); - - let mut handle = stdout.lock(); - for res in results { - match res { - Ok((d, h)) => { - if !args.quiet { - writeln!( - handle, - "{} {} {}", - h.to_hex(), - d.size.0, - d.relative_path.as_str() - ) - .map_err(CoreError::Io)?; - } - if let Some(ref mut r) = repo { - match persist_file(*r, &d, &h, device, volume) { - Ok(outcome) => match outcome { - UpsertOutcome::Inserted => stats.new += 1, - UpsertOutcome::Updated | UpsertOutcome::Unchanged => { - stats.existing += 1; - } - }, - Err(e) => { - tracing::warn!(error = %e, "persist failed"); - stats.errors += 1; - } - } - } else { - stats.new += 1; - } - } - Err(e) => { - tracing::warn!(error = %e, "skipping file: hash failed"); - stats.errors += 1; - } - } - } - drop(handle); - - let interrupted = cancel.cancelled(); - let suffix = if interrupted { " (interrupted)" } else { "" }; - if args.dry_run { - let total = stats.new + stats.existing + stats.errors; - eprintln!("scanned {total} files (dry-run; DB not wired){suffix}"); - } else { - let vol_short = &volume.0.to_string()[..8]; - eprintln!( - "scanned {} files on volume {vol_short} ({} new, {} existing, {} errors){suffix}", - stats.new + stats.existing + stats.errors, - stats.new, - stats.existing, - stats.errors - ); - } - - Ok(( - if interrupted { - ExitCode::Interrupted - } else { - ExitCode::Success - }, - stats, - )) -} - -fn persist_file( - repo: &mut R, - d: &DiscoveredFile, - h: &BlakeHash, - device: DeviceId, - volume: VolumeId, -) -> Result { - let hf = perima_core::HashedFile { - discovered: d.clone(), - hash: *h, - }; - repo.upsert_file(&hf, device)?; - repo.upsert_location(h, volume, &d.relative_path, device) -} - -fn validate_root(root: &Path) -> Result<(), CoreError> { - if !root.exists() { - return Err(CoreError::InvalidPath(format!( - "does not exist: {}", - root.display() - ))); - } - if !root.is_dir() { - return Err(CoreError::InvalidPath(format!( - "not a directory: {}", - root.display() - ))); - } - Ok(()) -} - -fn canonicalize_for_walk(root: &Path) -> Result { - dunce::canonicalize(root).map_err(CoreError::Io) -} -``` - -Note: `repo` is `Option<&mut R>` — when `dry_run` is true, the caller passes `None`; when false, passes `Some(&mut sqlite_repo)`. This avoids the old `Unsupported` guard entirely. - -- [ ] **Step 2: Run gates** - -Run: `cargo build -p perima` → exit 0 (will fail until main.rs is updated in Task 7). - -Expected: compile errors in main.rs because `scan::run` signature changed. Continue to Task 6+7. - ---- - -## Task 6: CLI — `cmd/ls.rs` - -**Files:** -- Create: `crates/cli/src/cmd/ls.rs` -- Modify: `crates/cli/src/cmd/mod.rs` - -- [ ] **Step 1: Create `ls.rs`** - -```rust -//! `perima ls` implementation. - -use std::io::Write; - -use perima_core::{CoreError, FileLocationRecord, FileRepository, VolumeId}; - -/// Arguments for the ls command. -#[derive(Debug, Clone)] -pub struct LsArgs { - /// Filter to a specific volume. - pub volume: Option, - /// Maximum number of rows to return. - pub limit: usize, - /// Output as JSON instead of a human-readable table. - pub json: bool, -} - -/// Execute `ls`. -/// -/// # Errors -/// Propagates `CoreError` from the repository. -pub fn run( - repo: &R, - args: &LsArgs, -) -> Result<(), CoreError> { - let records = repo.list_file_locations(args.limit, args.volume)?; - - if args.json { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - serde_json::to_writer_pretty(&mut handle, &records) - .map_err(|e| CoreError::Internal(format!("json: {e}")))?; - writeln!(handle).map_err(CoreError::Io)?; - } else { - print_table(&records)?; - } - Ok(()) -} - -fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - writeln!(handle, "{:<10} {:<10} {:<10} {}", "HASH", "SIZE", "VOLUME", "PATH") - .map_err(CoreError::Io)?; - for r in records { - let hash_short = &r.hash.to_hex()[..8]; - let vol_short = &r.volume_id.0.to_string()[..8]; - let size = format_size(r.size.0); - writeln!(handle, "{hash_short}… {size:<10} {vol_short}… {}", r.relative_path.as_str()) - .map_err(CoreError::Io)?; - } - Ok(()) -} - -fn format_size(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = 1024 * KB; - const GB: u64 = 1024 * MB; - if bytes >= GB { - format!("{:.1} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1} KB", bytes as f64 / KB as f64) - } else { - format!("{bytes} B") - } -} -``` - -- [ ] **Step 2: Update `cmd/mod.rs`** - -```rust -//! CLI subcommand modules. - -pub mod ls; -pub mod scan; -``` - -- [ ] **Step 3: Run gates — expect compile fail until main.rs updated** - ---- - -## Task 7: CLI — update `main.rs` - -**Files:** -- Modify: `crates/cli/src/main.rs` - -- [ ] **Step 1: Full rewrite** - -```rust -//! `perima` command-line entry point. - -mod cmd; -mod config; -mod logging; -mod panic; -mod signals; - -use std::path::PathBuf; -use std::process::ExitCode; - -use clap::{Parser, Subcommand}; -use perima_core::VolumeId; -use perima_db::{SqliteFileRepository, open_and_migrate}; -use perima_fs::WalkdirScanner; -use perima_hash::Blake3Service; - -/// Cross-platform media asset manager. -#[derive(Parser, Debug)] -#[command( - name = "perima", - version, - about = "Index your media across drives by content hash" -)] -struct Cli { - /// Bump tracing verbosity; repeatable (-v, -vv). - #[arg(short, long, action = clap::ArgAction::Count, global = true)] - verbose: u8, - - /// Override the main database directory. - #[arg(long, global = true)] - data_dir: Option, - - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Walk a directory, hash every file, and persist to the database. - Scan { - /// Directory to walk. - root: PathBuf, - - /// Dry-run mode: hash and print, skip DB writes. - #[arg(long)] - dry_run: bool, - - /// Suppress per-file stdout lines. - #[arg(long)] - quiet: bool, - }, - - /// List indexed files. - Ls { - /// Filter to a specific volume UUID. - #[arg(long)] - volume: Option, - - /// Maximum rows to return. - #[arg(long, default_value = "100")] - limit: usize, - - /// Output as JSON. - #[arg(long)] - json: bool, - }, -} - -fn main() -> ExitCode { - panic::install(); - let cli = Cli::parse(); - - if let Err(e) = logging::init(cli.verbose) { - eprintln!("perima: logging init failed: {e}"); - return ExitCode::from(1); - } - - let cancel = match signals::install() { - Ok(c) => c, - Err(e) => { - eprintln!("perima: signal handler install failed: {e}"); - return ExitCode::from(1); - } - }; - - let config = match config::Config::resolve(cli.data_dir.clone()) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: config resolution failed: {e}"); - return ExitCode::from(1); - } - }; - - match cli.command { - Command::Scan { - root, - dry_run, - quiet, - } => { - let args = cmd::scan::ScanArgs { - root, - dry_run, - quiet, - }; - let scanner = WalkdirScanner::new(); - let hasher = Blake3Service::new(); - - // WHY: sentinel VolumeId (all-zeros) is used until - // phase 1c wires real volume detection. 1c will UPDATE - // file_locations SET volume_id = WHERE volume_id - // = '00000000-...' after resolving actual volumes. - let volume = VolumeId(uuid::Uuid::nil()); - - if dry_run { - // WHY 'static: the type parameter R is never - // instantiated (repo = None), but Rust needs a - // concrete type for inference. 'static is valid - // because no SqliteFileRepository is actually created. - match cmd::scan::run::<_, _, perima_db::SqliteFileRepository<'static>>( - &scanner, - &hasher, - None, - config.device_id, - volume, - &cancel, - &args, - ) { - Ok((cmd::scan::ExitCode::Success, _)) => ExitCode::from(0), - Ok((cmd::scan::ExitCode::Interrupted, _)) => ExitCode::from(130), - Err(perima_core::CoreError::InvalidPath(msg)) => { - eprintln!("perima: {msg}"); - ExitCode::from(2) - } - Err(e) => { - eprintln!("perima: {e}"); - ExitCode::from(1) - } - } - } else { - let db_path = config.data_dir.join("perima.db"); - let conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database: {e}"); - return ExitCode::from(1); - } - }; - let mut repo = SqliteFileRepository::new(&conn); - match cmd::scan::run( - &scanner, - &hasher, - Some(&mut repo), - config.device_id, - volume, - &cancel, - &args, - ) { - Ok((cmd::scan::ExitCode::Success, _)) => ExitCode::from(0), - Ok((cmd::scan::ExitCode::Interrupted, _)) => ExitCode::from(130), - Err(perima_core::CoreError::InvalidPath(msg)) => { - eprintln!("perima: {msg}"); - ExitCode::from(2) - } - Err(e) => { - eprintln!("perima: {e}"); - ExitCode::from(1) - } - } - } - } - - Command::Ls { - volume, - limit, - json, - } => { - let volume_id = volume - .map(|v| { - uuid::Uuid::parse_str(&v) - .map(VolumeId) - .map_err(|e| format!("bad volume UUID: {e}")) - }) - .transpose(); - let volume_id = match volume_id { - Ok(v) => v, - Err(msg) => { - eprintln!("perima: {msg}"); - return ExitCode::from(2); - } - }; - - let db_path = config.data_dir.join("perima.db"); - let conn = match open_and_migrate(&db_path) { - Ok(c) => c, - Err(e) => { - eprintln!("perima: database: {e}"); - return ExitCode::from(1); - } - }; - let repo = SqliteFileRepository::new(&conn); - let args = cmd::ls::LsArgs { - volume: volume_id, - limit, - json, - }; - match cmd::ls::run(&repo, &args) { - Ok(()) => ExitCode::from(0), - Err(e) => { - eprintln!("perima: {e}"); - ExitCode::from(1) - } - } - } - } -} -``` - -- [ ] **Step 2: Add `serde_json` dep to cli Cargo.toml** - -Add to `[dependencies]`: - -```toml -serde_json.workspace = true -serde.workspace = true -``` - -- [ ] **Step 3: Run the gates** - -Run: `just ci` → exit 0 (all previous tests + db tests). -Run: `cargo run -p perima -- --help` → shows `scan` and `ls` subcommands. - -- [ ] **Step 4: Dispatch reviewer (checkpoint #2 — CLI changes)** - -Reviewer checklist: -- [ ] `scan.rs` no longer has `Unsupported` guard; `--dry-run` is optional. -- [ ] `scan` opens DB + migrates when not `dry_run`; uses sentinel `VolumeId(Uuid::nil())`. -- [ ] `ls.rs` reads via `list_file_locations`, human table + JSON paths. -- [ ] `main.rs` wires both commands; both DB-touching paths call `open_and_migrate`. -- [ ] `just ci` green. All previous 27+ tests pass. -- [ ] WHY comment on sentinel VolumeId in main.rs. - -- [ ] **Step 5: Commit (after APPROVED)** - -```bash -git add crates/cli/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-1b): scan persists to DB, perima ls reads - -scan.rs: drop phase-1a Unsupported guard; --dry-run now optional -(default off). When dry_run=false, opens DB via open_and_migrate, -constructs SqliteFileRepository, persists upsert_file + -upsert_location per hashed file. Summary prints new/existing/error -counts. Sentinel VolumeId(Uuid::nil()) until 1c volume detection. - -ls.rs: new command reading via FileRepository::list_file_locations. -Human table (HASH SIZE VOLUME PATH) and --json (serde_json -pretty-print of Vec). --volume filter and ---limit supported. - -main.rs: both scan (non-dry-run) and ls call open_and_migrate so -perima ls on a fresh install creates the DB and runs migrations. - -Refs: docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 8: Integration tests - -**Files:** -- Create: `crates/cli/tests/scan_persists.rs` -- Create: `crates/cli/tests/ls_output.rs` -- Modify: `crates/cli/tests/scan_dry_run.rs` - -- [ ] **Step 1: Create `scan_persists.rs`** - -```rust -//! Integration test: scan without --dry-run persists to DB. - -use std::io::Write; -use std::path::Path; -use std::process::Command; - -fn mk_fixture(dir: &Path) { - for (name, content) in [ - ("alpha.txt", b"alpha" as &[u8]), - ("sub/beta.txt", b"beta"), - ("sub/gamma.bin", b"\x00\x01\x02\x03"), - ] { - let path = dir.join(name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("mkdir"); - } - std::fs::File::create(&path) - .expect("create") - .write_all(content) - .expect("write"); - } -} - -const fn bin() -> &'static str { - env!("CARGO_BIN_EXE_perima") -} - -#[test] -fn scan_persists_three_files() { - let td = tempfile::tempdir().expect("tempdir"); - let env_dir = tempfile::tempdir().expect("env dir"); - mk_fixture(td.path()); - - let output = Command::new(bin()) - .arg("scan") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", env_dir.path()) - .env("PERIMA_DATA_DIR", env_dir.path()) - .output() - .expect("run"); - - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - let stderr = String::from_utf8(output.stderr).expect("utf8"); - assert!(stderr.contains("3 new"), "expected '3 new' in: {stderr}"); - - // Open the DB directly and count rows. - let db_path = env_dir.path().join("perima.db"); - let conn = rusqlite::Connection::open(&db_path).expect("open db"); - let file_count: i64 = conn - .query_row("SELECT count(*) FROM files", [], |r| r.get(0)) - .expect("count files"); - assert_eq!(file_count, 3); - let loc_count: i64 = conn - .query_row("SELECT count(*) FROM file_locations", [], |r| r.get(0)) - .expect("count locations"); - assert_eq!(loc_count, 3); -} - -#[test] -fn rescan_produces_zero_new() { - let td = tempfile::tempdir().expect("tempdir"); - let env_dir = tempfile::tempdir().expect("env dir"); - mk_fixture(td.path()); - - let run = || { - Command::new(bin()) - .arg("scan") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", env_dir.path()) - .env("PERIMA_DATA_DIR", env_dir.path()) - .output() - .expect("run") - }; - - let first = run(); - assert!(first.status.success()); - - let second = run(); - assert!(second.status.success()); - let stderr = String::from_utf8(second.stderr).expect("utf8"); - assert!(stderr.contains("0 new"), "expected '0 new' in second scan: {stderr}"); -} -``` - -- [ ] **Step 2: Create `ls_output.rs`** - -```rust -//! Integration test: perima ls after scan. - -use std::io::Write; -use std::path::Path; -use std::process::Command; - -fn mk_fixture(dir: &Path) { - for (name, content) in [ - ("alpha.txt", b"alpha" as &[u8]), - ("sub/beta.txt", b"beta"), - ("sub/gamma.bin", b"\x00\x01\x02\x03"), - ] { - let path = dir.join(name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("mkdir"); - } - std::fs::File::create(&path) - .expect("create") - .write_all(content) - .expect("write"); - } -} - -const fn bin() -> &'static str { - env!("CARGO_BIN_EXE_perima") -} - -fn scan_first(td: &Path, env_dir: &Path) { - let output = Command::new(bin()) - .arg("scan") - .arg(td) - .env("PERIMA_CONFIG_DIR", env_dir) - .env("PERIMA_DATA_DIR", env_dir) - .output() - .expect("scan"); - assert!(output.status.success(), "scan failed: {}", String::from_utf8_lossy(&output.stderr)); -} - -#[test] -fn ls_shows_three_files() { - let td = tempfile::tempdir().expect("tempdir"); - let env_dir = tempfile::tempdir().expect("env dir"); - mk_fixture(td.path()); - scan_first(td.path(), env_dir.path()); - - let output = Command::new(bin()) - .arg("ls") - .arg("--limit") - .arg("10") - .env("PERIMA_CONFIG_DIR", env_dir.path()) - .env("PERIMA_DATA_DIR", env_dir.path()) - .output() - .expect("ls"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - let lines: Vec<&str> = stdout.lines().collect(); - // Header + 3 data lines. - assert_eq!(lines.len(), 4, "expected header + 3 lines, got: {lines:?}"); -} - -#[test] -fn ls_json_deserializes() { - let td = tempfile::tempdir().expect("tempdir"); - let env_dir = tempfile::tempdir().expect("env dir"); - mk_fixture(td.path()); - scan_first(td.path(), env_dir.path()); - - let output = Command::new(bin()) - .arg("ls") - .arg("--json") - .env("PERIMA_CONFIG_DIR", env_dir.path()) - .env("PERIMA_DATA_DIR", env_dir.path()) - .output() - .expect("ls --json"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - let records: Vec = - serde_json::from_str(&stdout).expect("deserialize"); - assert_eq!(records.len(), 3); -} -``` - -- [ ] **Step 3: Update `scan_dry_run.rs`** - -Remove the `real_scan_refused_in_phase_1a` test (the guard no longer exists). Replace with: - -```rust -#[test] -fn scan_without_dry_run_succeeds() { - let td = tempfile::tempdir().expect("tempdir"); - mk_fixture(td.path()); - let tmp_env = tempfile::tempdir().expect("env dir"); - - let output = Command::new(bin()) - .arg("scan") - .arg(td.path()) - .env("PERIMA_CONFIG_DIR", tmp_env.path()) - .env("PERIMA_DATA_DIR", tmp_env.path()) - .output() - .expect("run perima"); - - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - let stderr = String::from_utf8(output.stderr).expect("utf8"); - assert!(stderr.contains("scanned"), "missing summary in: {stderr}"); -} -``` - -- [ ] **Step 4: Add dev-deps to `crates/cli/Cargo.toml`** - -Add to `[dev-dependencies]`: - -```toml -rusqlite.workspace = true -serde_json.workspace = true -``` - -- [ ] **Step 5: Run the gates** - -Run: `cargo test --workspace` → all tests pass (db: 11, cli unit: 2, cli integration: ~8, core: 10, core props: 3, fs: 6, hash: 3 = ~43 total). -Run: `just ci` → exit 0. - -- [ ] **Step 6: Dispatch reviewer (checkpoint #3 — integration tests)** - -Reviewer checklist: -- [ ] `scan_persists.rs`: 2 tests (persist + rescan-zero-new). Opens DB with raw rusqlite to assert row counts. -- [ ] `ls_output.rs`: 2 tests (human table has header+3, JSON deserializes back to `Vec`). -- [ ] `scan_dry_run.rs`: old guard test replaced with `scan_without_dry_run_succeeds`. -- [ ] `--dry-run` path still works (existing tests). -- [ ] All phase-1a tests still pass. -- [ ] `just ci` green. - -- [ ] **Step 7: Commit (after APPROVED)** - -```bash -git add crates/cli/ Cargo.lock -git commit -m "$(cat <<'EOF' -test(phase-1b): integration tests for scan persist + ls output - -scan_persists.rs: scan without --dry-run writes 3 files + 3 -file_locations to SQLite (verified by opening DB with raw -rusqlite); rescan produces 0 new rows. - -ls_output.rs: human table has header + 3 data lines; --json -output deserializes back to Vec. - -scan_dry_run.rs: removed phase-1a guard test (guard is gone); -added scan_without_dry_run_succeeds asserting exit 0 + summary. - -Refs: docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 9: Final sweep + push + tag - -- [ ] **Step 1: Clean build sweep** - -Run: `cargo clean && just ci` -Expected: exit 0. - -- [ ] **Step 2: WHY-comment check** - -Run: `grep -rE '^\s*//\s*WHY:' crates/db/ crates/cli/ | wc -l` -Expected: ≥ 3 new (WAL pragma, blake3_hash PK in SQL, app-level uniqueness, sentinel VolumeId). - -- [ ] **Step 3: `.md` hygiene** - -Run: `git ls-files '*.md'` -Expected: empty. - -- [ ] **Step 4: Final reviewer (checkpoint #4)** - -Reviewer checklist: -- [ ] All exit criteria B1–B11 from the spec met. -- [ ] `just ci` green. -- [ ] WHY comments present for all spec-mandated items. -- [ ] No `.md` files tracked. - -- [ ] **Step 5: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -- [ ] **Step 6: Tag (ONLY after CI green)** - -```bash -git tag -a phase-1b-complete -m "Phase 1b: DB adapter + perima ls" -git push origin phase-1b-complete -``` - ---- - -## Self-review - -**Spec coverage:** -- Schema (V001) → Task 3 ✓ -- Connection + WAL pragmas → Task 3 ✓ -- `db::Error` + rich `From` → Task 2 ✓ -- `SqliteFileRepository` (upsert_file, upsert_location, list) → Task 4 ✓ -- `open_and_migrate` called from both scan + ls → Task 7 ✓ -- Sentinel `VolumeId(Uuid::nil())` → Task 7 ✓ -- `scan.rs` remove guard + persist path → Task 5 ✓ -- `perima ls` human + JSON → Task 6 ✓ -- Integration tests → Task 8 ✓ -- Exit criteria B1–B11 → Task 9 ✓ - -**Placeholder scan:** no TBD/TODO. Every file has complete content. - -**Type consistency:** `SqliteFileRepository<'conn>` matches between file_repo.rs and main.rs. `ScanStats` + return type `(ExitCode, ScanStats)` consistent between scan.rs and main.rs. `LsArgs` consistent between ls.rs and main.rs. `FileLocationRecord` used consistently for JSON round-trip. - -**Commit discipline:** three reviewer-gated commits (db, CLI, integration tests) + final push/tag. Matches CLAUDE.md "execute → tests → reviewer → commit." diff --git a/docs/superpowers/plans/2026-04-16-phase-1c-volumes-manifest.md b/docs/superpowers/plans/2026-04-16-phase-1c-volumes-manifest.md deleted file mode 100644 index cf34a64..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-1c-volumes-manifest.md +++ /dev/null @@ -1,512 +0,0 @@ -# Phase 1c — Volumes, Manifest, `perima volumes` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the sentinel `VolumeId(Uuid::nil())` with real volume detection via `sysinfo`. Implement `VolumeRepository` in `crates/db`. Write `.perima/manifest.db` at each volume root during scan. Ship `perima volumes`. Fix sentinel rows from 1b. Tag `phase-1-complete`. - -**Architecture:** `crates/fs/volumes.rs` detects volumes via `sysinfo::Disks` (longest mount-prefix match). `crates/db/volume_repo.rs` impls `VolumeRepository` (priority-chain match: label+capacity for v1). `crates/db/manifest.rs` creates `.perima/manifest.db` per volume. `crates/cli` wires volume detection into scan, adds `perima volumes` command, migrates sentinel rows per-file. - -**Tech Stack:** sysinfo 0.38, rusqlite 0.38 (bundled), chrono 0.4, uuid v7. Existing: blake3, walkdir, clap 4, rayon, ctrlc, tempfile. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md` - -**Execution rule:** All work on `main`. Per-commit: execute → `just ci` green → reviewer → commit. No branches/force. - ---- - -## File Structure - -``` -crates/core/src/ports/volume_repo.rs # modify — add machine param to list() -crates/fs/ -├── Cargo.toml # modify — add sysinfo dep -└── src/ - ├── lib.rs # modify — add volumes module - └── volumes.rs # new — volume detection - -crates/db/src/ -├── lib.rs # modify — add volume_repo + manifest -├── volume_repo.rs # new — SqliteVolumeRepository -└── manifest.rs # new — per-drive manifest writer - -crates/cli/src/ -├── main.rs # modify — wire volume detection, add Volumes cmd -└── cmd/ - ├── mod.rs # modify — add volumes module - ├── scan.rs # modify — accept VolumeRepository, sentinel migration - └── volumes.rs # new — perima volumes command - -crates/cli/tests/ -├── scan_with_volumes.rs # new -├── volumes_output.rs # new -└── manifest_created.rs # new -``` - -Three reviewer-gated commits: (1) fs volumes + db volume_repo + manifest, (2) CLI wiring, (3) integration tests + push + tag. - ---- - -## Task 1: Fix `VolumeRepository::list` trait signature - -**Files:** -- Modify: `crates/core/src/ports/volume_repo.rs` - -- [ ] **Step 1: Add `DeviceId` parameter to `list`** - -Change the `list` method signature from: - -```rust -fn list(&self) -> Result, CoreError>; -``` - -to: - -```rust -/// Enumerate all known volumes with their current mounts for -/// the given `machine`. -/// -/// # Errors -/// `CoreError::Internal` on adapter failure. -fn list(&self, machine: DeviceId) -> Result, CoreError>; -``` - -- [ ] **Step 2: Verify workspace builds** - -Run: `cargo build --workspace` -Expected: exit 0 (no code implements this trait yet with the wrong sig). - ---- - -## Task 2: Volume detection (`crates/fs/src/volumes.rs`) - -**Files:** -- Modify: `crates/fs/Cargo.toml` — add `sysinfo.workspace = true` -- Create: `crates/fs/src/volumes.rs` -- Modify: `crates/fs/src/lib.rs` - -- [ ] **Step 1: Add sysinfo dep** - -Add to `crates/fs/Cargo.toml` `[dependencies]`: - -```toml -sysinfo.workspace = true -``` - -- [ ] **Step 2: Write `volumes.rs` with tests** - -The implementer should create `crates/fs/src/volumes.rs`. The module should: - -1. Define `DetectedVolume { identifiers: VolumeIdentifiers, mount_point: PathBuf }`. -2. Implement `pub fn detect_volume(path: &Path) -> Result` that: - - Canonicalizes `path` via `dunce::canonicalize`. - - Uses `sysinfo::Disks::new_with_refreshed_list()` to enumerate disks. - - Finds the disk whose `mount_point()` is the longest prefix of the canonicalized path. - - Populates `VolumeIdentifiers` from the matched disk: `gpt_partition_guid = None`, `fs_uuid = None` (v1 honest assessment — sysinfo doesn't expose these), `label` from `disk.name().to_string_lossy()`, `capacity_bytes` from `disk.total_space()`, `is_removable` from `disk.is_removable()`. - - Returns `CoreError::Internal("no volume found for path: ...")` if no disk matches. -3. Include a `// WHY: v1 matching is label+capacity only; sysinfo 0.38 does not expose GPT GUID or fs_uuid on any platform. The priority-chain structure supports plugging in richer identifiers later.` comment. -4. Include tests: - - `detect_volume_on_cwd` — `detect_volume(std::env::current_dir())` returns `Ok` with non-zero capacity (smoke test). - - `detect_volume_nonexistent_path` — `detect_volume(Path::new("/definitely/does/not/exist"))` returns `Err`. - -- [ ] **Step 3: Update `crates/fs/src/lib.rs`** - -Add `pub mod volumes;` and `pub use volumes::{DetectedVolume, detect_volume};`. - -- [ ] **Step 4: Run gates** - -Run: `cargo test -p perima-fs` → existing 6 + 2 new = 8 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 3: `SqliteVolumeRepository` (`crates/db/src/volume_repo.rs`) — TDD - -**Files:** -- Create: `crates/db/src/volume_repo.rs` -- Modify: `crates/db/src/lib.rs` - -The implementer should create `crates/db/src/volume_repo.rs`. The module should: - -1. Define `pub struct SqliteVolumeRepository` wrapping `Mutex` (same pattern as `SqliteFileRepository`). -2. Implement `VolumeRepository` for it with the three methods: - - `find_or_create`: priority-chain SELECT (label+capacity for v1, GUID/fs_uuid arms for future). If matched, UPDATE `last_seen` + `updated_at`. If not, INSERT new row with `VolumeId::new()`. - - `record_mount`: app-level uniqueness on `(volume_id, machine_id, deleted_at IS NULL)`. SELECT-then-INSERT/UPDATE. - - `list(machine)`: LEFT JOIN volumes + volume_mounts filtered by `machine_id`, grouped by `volume_id`. Return `Vec`. -3. Include a `// WHY: priority chain tries GUID first, then fs_uuid, then label+capacity. v1 only has label+capacity; the structure exists so future sysinfo upgrades or blkid integration slot in without refactoring the match logic.` comment. -4. Include **6 tests** (TDD — write tests first, verify compile fail, then implement): - - `find_or_create_inserts_new` → returns a `VolumeId`. - - `find_or_create_matches_on_label_capacity` → same `VolumeId` when label+capacity match. - - `find_or_create_guid_trumps_label` — insert vol A with GUID "X" + label "A"; insert vol B with label "B"; then `find_or_create` with GUID "X" + label "B" should return vol A's id (GUID wins). - - `record_mount_inserts_new` → inserts. - - `record_mount_unchanged_on_repeat` → no error, unchanged. - - `list_returns_volumes_with_mounts` → after find_or_create + record_mount, list returns 1 volume with 1 mount path. - -- [ ] **Step 1: Write tests first, verify compile fail** -- [ ] **Step 2: Implement `SqliteVolumeRepository`** -- [ ] **Step 3: Update `crates/db/src/lib.rs`** — add `pub mod volume_repo;` + `pub use volume_repo::SqliteVolumeRepository;` -- [ ] **Step 4: Run gates** - -Run: `cargo test -p perima-db` → 11 existing + 6 new = 17 tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 4: Manifest writer (`crates/db/src/manifest.rs`) - -**Files:** -- Create: `crates/db/src/manifest.rs` -- Modify: `crates/db/src/lib.rs` - -The implementer should create `crates/db/src/manifest.rs`. The module should: - -1. Define `pub fn write_manifest(volume_root: &Path, volume_id: VolumeId, files: &[HashedFile]) -> Result<(), CoreError>`. -2. Implementation: - - Create `/.perima/` directory if missing. - - Open (or create) `/.perima/manifest.db` via `rusqlite::Connection::open`. - - Run `CREATE TABLE IF NOT EXISTS manifest_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`. - - Run `CREATE TABLE IF NOT EXISTS manifest_files (blake3_hash TEXT PRIMARY KEY, file_size INTEGER NOT NULL, relative_path TEXT NOT NULL, first_seen TEXT NOT NULL, updated_at TEXT NOT NULL)`. - - Upsert `manifest_meta` rows: `volume_id`, `manifest_version` = "1", `created_at` (INSERT OR REPLACE). - - For each file: `INSERT OR REPLACE INTO manifest_files` with hash, size, relative_path, first_seen=now, updated_at=now. - - Include `// WHY: manifest uses INSERT OR REPLACE (not the main DB's app-level uniqueness pattern) because the manifest is a local recovery dump, not a CRDT-replicated table. Simplicity wins here.` -3. If the write fails (e.g., read-only volume), log a warning via `tracing::warn!` and return `Ok(())` — the manifest is a convenience, not a hard requirement. -4. Include **3 tests**: - - `write_manifest_creates_db` — creates `.perima/manifest.db` with correct `manifest_meta` rows. - - `write_manifest_writes_files` — 3 files → 3 `manifest_files` rows. - - `write_manifest_updates_on_rerun` — change a file's relative_path, re-run, verify updated row. - -- [ ] **Step 1: Write tests first, verify compile fail** -- [ ] **Step 2: Implement manifest writer** -- [ ] **Step 3: Update `crates/db/src/lib.rs`** — add `pub mod manifest;` -- [ ] **Step 4: Run gates** - -Run: `cargo test -p perima-db` → 17 + 3 = 20 tests pass. -Run: `just ci` → exit 0. - -- [ ] **Step 5: Dispatch reviewer (checkpoint #1 — fs volumes + db volume_repo + manifest)** - -Reviewer checklist: -- [ ] `detect_volume` uses longest mount-prefix match, returns `DetectedVolume`. -- [ ] WHY comment on v1 label+capacity reality. -- [ ] `SqliteVolumeRepository`: priority chain structure with label+capacity v1 path, GUID arm exists but tests show it works. -- [ ] `record_mount`: app-level uniqueness. -- [ ] `list(machine)`: LEFT JOIN, grouped by volume_id. -- [ ] Manifest: separate DB, INSERT OR REPLACE, creates `.perima/` dir, graceful failure on read-only. -- [ ] 20 db tests + 8 fs tests pass. `just ci` green. -- [ ] WHY comments present. - -- [ ] **Step 6: Commit (after APPROVED)** - -```bash -git add crates/core/src/ports/volume_repo.rs crates/fs/ crates/db/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-1c): volume detection, VolumeRepository, manifest writer - -crates/fs/volumes.rs: detect_volume via sysinfo longest mount-prefix -match. v1 matching is label+capacity only (sysinfo 0.38 does not -expose GPT GUID or fs_uuid); priority-chain structure supports future -richer identifiers. - -crates/db/volume_repo.rs: SqliteVolumeRepository impls VolumeRepository. -Priority-chain find_or_create (GUID -> fs_uuid -> label+capacity); -record_mount with app-level uniqueness; list(machine) with LEFT JOIN. - -crates/db/manifest.rs: write_manifest creates .perima/manifest.db at -volume root with manifest_meta + manifest_files tables. Uses INSERT OR -REPLACE (non-CRDT local recovery dump). Graceful failure on read-only -volumes (warn + continue). - -VolumeRepository::list trait signature updated to accept DeviceId for -machine-scoped mount filtering. - -Refs: docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 5: CLI — update `scan.rs` for volume detection + sentinel migration - -**Files:** -- Modify: `crates/cli/src/cmd/scan.rs` - -The implementer should modify `scan.rs`: - -1. Change `run` signature to accept an optional `VolumeRepository`: - ```rust - pub fn run( - scanner: &S, - hasher: &H, - mut file_repo: Option<&mut FR>, - mut volume_repo: Option<&mut VR>, - device: DeviceId, - cancel: &Cancellation, - args: &ScanArgs, - ) -> Result<(ExitCode, ScanStats), CoreError> - where - S: Scanner + ?Sized, - H: HashService + ?Sized, - FR: FileRepository + ?Sized, - VR: VolumeRepository + ?Sized, - ``` - Note: `volume: VolumeId` parameter is REMOVED — the function detects it internally when not dry-run. - -2. When `dry_run=false`: - - Call `perima_fs::detect_volume(&canonical_root)` to get `DetectedVolume`. - - Call `volume_repo.find_or_create(&detected.identifiers, device)` to get real `VolumeId`. - - Call `volume_repo.record_mount(volume_id, device, &detected.mount_point)`. - - Use this real `VolumeId` for all `upsert_location` calls. - -3. Sentinel migration: inside the persist loop, after `upsert_location`, add: - ```rust - // Migrate sentinel rows from 1b for this specific path. - if let Some(ref mut fr) = file_repo { - // Use a raw SQL approach via a new method or direct conn access. - // For simplicity, add a migrate_sentinel method to FileRepository. - } - ``` - - Actually, the sentinel migration should be a standalone function in `crates/db` rather than polluting the `FileRepository` trait. Add a public function: - ```rust - // crates/db/src/file_repo.rs (or a new sentinel.rs) - pub fn migrate_sentinel_for_path( - conn: &Mutex, - relative_path: &str, - real_volume_id: &str, - device_id: &str, - ) -> Result - ``` - - But since `SqliteFileRepository` owns the `Mutex`, expose it via a method: - ```rust - impl SqliteFileRepository { - pub fn migrate_sentinel_row( - &self, - relative_path: &MediaPath, - real_volume: VolumeId, - device: DeviceId, - ) -> Result - } - ``` - This runs the scoped UPDATE from the spec. - -4. When `dry_run=true`: pass `None` for both repos, use a placeholder `VolumeId` (doesn't matter — no DB). - -5. Update the summary line: when not dry-run, print `"scanned N files on volume (...)"` where label comes from the detected volume. - -- [ ] **Step 1: Add `migrate_sentinel_row` to `SqliteFileRepository` (in crates/db)** -- [ ] **Step 2: Rewrite `scan.rs` with the new signature + volume detection + sentinel migration** -- [ ] **Step 3: Run gates** — expect compile fail until main.rs updated. Continue. - ---- - -## Task 6: CLI — `cmd/volumes.rs` - -**Files:** -- Create: `crates/cli/src/cmd/volumes.rs` -- Modify: `crates/cli/src/cmd/mod.rs` - -The implementer should create `volumes.rs`: - -1. `pub fn run(repo: &VR, machine: DeviceId) -> Result<(), CoreError>`. -2. Calls `repo.list(machine)`, prints a table: - ``` - VOLUME ID LABEL REMOVABLE CAPACITY MOUNT PATHS - f0e9a1b2… BACKUP_SSD yes 2.0 TB /mnt/backup - ``` -3. Re-use the `format_size` function from `ls.rs` (move it to a shared `cmd/format.rs` or just duplicate — plan author's choice; I recommend a small `cmd/format.rs` to avoid duplication). - -- [ ] **Step 1: Create `cmd/format.rs`** with the shared `format_size` function (move from ls.rs). -- [ ] **Step 2: Update `cmd/ls.rs`** to use `cmd::format::format_size`. -- [ ] **Step 3: Create `cmd/volumes.rs`**. -- [ ] **Step 4: Update `cmd/mod.rs`** — add `pub mod format;` and `pub mod volumes;`. - ---- - -## Task 7: CLI — update `main.rs` - -**Files:** -- Modify: `crates/cli/src/main.rs` - -The implementer should: - -1. Add `Volumes` variant to `Command` enum (no args). -2. Update `dispatch_scan`: - - Remove the sentinel `VolumeId(Uuid::nil())` line. - - For non-dry-run: open DB, create both `SqliteFileRepository` and `SqliteVolumeRepository` from the same connection... **Wait — they each wrap `Mutex`, so they each take ownership.** This means two repos can't share one connection. - - **Solution:** open two connections (one for file_repo, one for volume_repo). Under WAL mode this is safe — multiple readers/one writer, and scan's writes go through file_repo while volume_repo writes happen before the scan loop. Alternatively, pass the same `Connection` to both via `Arc>`. The simplest approach for v1: **open the DB twice** (`open_and_migrate` is idempotent; the second call is instant because migrations already ran). - -3. For dry-run: pass `None` for both repos (turbofish needs two type params now). -4. Add `dispatch_volumes` function. -5. Import `perima_db::SqliteVolumeRepository` and `perima_fs::detect_volume`. - -- [ ] **Step 1: Rewrite main.rs** -- [ ] **Step 2: Run gates** - -Run: `just ci` → exit 0. -Run: `cargo run -p perima -- --help` → shows `scan`, `ls`, `volumes`. -Run: `cargo run -p perima -- volumes` → shows at least 1 volume. -Run: `cargo run -p perima -- scan ` → uses real volume, summary shows volume label. - -- [ ] **Step 3: Dispatch reviewer (checkpoint #2 — CLI wiring)** - -Reviewer checklist: -- [ ] `scan.rs` calls `detect_volume`, `find_or_create`, `record_mount`. -- [ ] Sentinel migration runs per-file via `migrate_sentinel_row`. -- [ ] `volumes.rs` prints table. -- [ ] `main.rs` wires both repos (two DB connections under WAL). -- [ ] `just ci` green. All existing tests pass. -- [ ] WHY comments on sentinel migration scoping. - -- [ ] **Step 4: Commit (after APPROVED)** - -```bash -git add crates/cli/ crates/db/src/file_repo.rs Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-1c): scan uses real volume detection, perima volumes - -scan.rs: detect_volume via sysinfo, find_or_create + record_mount -via VolumeRepository, sentinel migration per-file (scoped by -relative_path). Summary shows volume label. - -volumes.rs: new command listing detected volumes with mount paths. - -main.rs: wires SqliteVolumeRepository (second DB connection under -WAL). format.rs extracted from ls.rs for shared format_size. - -migrate_sentinel_row on SqliteFileRepository: scoped UPDATE of -sentinel VolumeId rows by relative_path. - -Refs: docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 8: Integration tests - -**Files:** -- Create: `crates/cli/tests/scan_with_volumes.rs` -- Create: `crates/cli/tests/volumes_output.rs` -- Create: `crates/cli/tests/manifest_created.rs` - -The implementer should create 3 integration test files: - -### `scan_with_volumes.rs` (2 tests): -1. `scan_uses_real_volume` — scan 3 fixtures, open DB with rusqlite, assert: - - `volumes` table has 1 row. - - `volume_mounts` table has 1 row. - - All `file_locations.volume_id` are NOT `'00000000-0000-0000-0000-000000000000'`. -2. `sentinel_rows_migrated` — pre-populate the DB with a sentinel file_location row (using raw rusqlite), then run scan on the same fixture. Assert the sentinel row's `volume_id` is now the real one (not all-zeros). - -### `volumes_output.rs` (1 test): -1. `volumes_shows_one_volume` — scan a tmpdir, then `perima volumes`, assert stdout has header + at least 1 data line. - -### `manifest_created.rs` (1 test): -1. `manifest_db_created_after_scan` — scan a tmpdir, assert `/.perima/manifest.db` exists, open it with rusqlite, assert `manifest_files` has 3 rows and `manifest_meta` has `volume_id` key. - -All tests use `env!("CARGO_BIN_EXE_perima")`, tmpdir fixtures, `PERIMA_CONFIG_DIR`/`PERIMA_DATA_DIR` env overrides. - -- [ ] **Step 1: Write `scan_with_volumes.rs`** -- [ ] **Step 2: Write `volumes_output.rs`** -- [ ] **Step 3: Write `manifest_created.rs`** -- [ ] **Step 4: Run gates** - -Run: `cargo test --workspace` → all tests pass. -Run: `just ci` → exit 0. - ---- - -## Task 9: Final sweep + push + tag - -- [ ] **Step 1: WHY-comment check** - -Run: `grep -rE '^\s*//\s*WHY:' crates/ | wc -l` -Expected: ≥ 3 new (priority chain, sentinel migration scoping, manifest non-CRDT). - -- [ ] **Step 2: Clean build sweep** - -Run: `cargo clean && just ci` -Expected: exit 0. - -- [ ] **Step 3: `.md` hygiene** - -Run: `git ls-files '*.md'` -Expected: empty. - -- [ ] **Step 4: Dispatch final reviewer (checkpoint #3)** - -Reviewer checklist: -- [ ] All exit criteria C1–C11 from the spec met. -- [ ] All 42+ previous tests pass. -- [ ] New integration tests cover volumes, sentinel migration, manifest. -- [ ] `just ci` green. -- [ ] WHY comments present. - -- [ ] **Step 5: Commit integration tests** - -```bash -git add crates/cli/tests/ Cargo.lock -git commit -m "$(cat <<'EOF' -test(phase-1c): integration tests for volumes + manifest - -scan_with_volumes.rs: scan uses real volume (non-sentinel); volumes + -volume_mounts tables populated. Sentinel migration verified. - -volumes_output.rs: perima volumes shows header + data lines. - -manifest_created.rs: .perima/manifest.db created with manifest_meta + -manifest_files rows. - -Refs: docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 6: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -- [ ] **Step 7: Tag (ONLY after CI green)** - -Note: tag is `phase-1-complete` (not `phase-1c-complete`) per the meta-plan — this completes all of phase 1. - -```bash -git tag -a phase-1-complete -m "Phase 1: indexing core + CLI (scan, ls, volumes)" -git push origin phase-1-complete -``` - ---- - -## Self-review - -**Spec coverage:** -- Volume detection (detect_volume) → Task 2 ✓ -- Priority chain (label+capacity v1, GUID/fs_uuid future) → Task 3 ✓ -- VolumeRepository impl (find_or_create, record_mount, list) → Task 3 ✓ -- Trait signature fix (list + DeviceId) → Task 1 ✓ -- Sentinel migration (per-file scoped) → Task 5 ✓ -- Manifest writer (.perima/manifest.db) → Task 4 ✓ -- scan.rs wiring → Task 5 ✓ -- perima volumes command → Task 6 ✓ -- main.rs wiring → Task 7 ✓ -- Integration tests → Task 8 ✓ -- Exit criteria C1–C11 → Task 9 ✓ -- ScanContext debt noted → spec only, no action in 1c ✓ - -**Placeholder scan:** no TBD/TODO. Tasks 2-4 say "the implementer should" with specific requirements rather than verbatim code — this is intentional for these tasks because clippy pedantic + the `Mutex` pattern requires adaptations that prior dispatches have shown can't be predicted byte-for-byte. The requirements are specific enough for a skilled implementer. - -**Type consistency:** `DetectedVolume` used consistently between fs/volumes.rs and cli/scan.rs. `SqliteVolumeRepository` matches between db/volume_repo.rs and main.rs. `migrate_sentinel_row` on `SqliteFileRepository` consistent between db and scan.rs. `format_size` extracted to `cmd/format.rs` used by both ls.rs and volumes.rs. - -**Commit discipline:** three reviewer-gated commits + push/tag at final checkpoint. diff --git a/docs/superpowers/plans/2026-04-16-phase-2a-tauri-backend.md b/docs/superpowers/plans/2026-04-16-phase-2a-tauri-backend.md deleted file mode 100644 index 781eea3..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-2a-tauri-backend.md +++ /dev/null @@ -1,376 +0,0 @@ -# Phase 2a — Tauri Backend Crate Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Create `crates/desktop` as a Tauri v2 backend crate exposing `scan`, `list_files`, and `list_volumes` as `#[tauri::command]` functions with `tauri-specta` type-safe bindings. Headless command tests prove the IPC contract without a browser window. - -**Architecture:** `crates/desktop` is a thin Tauri wrapper: `AppState` holds resolved `Config` + `DeviceId`. Each command opens its own DB connection (cheap under WAL, avoids lifetime issues with Tauri's async). Commands delegate to the same core logic the CLI uses. `tauri-specta` generates TS bindings at build time via `build.rs`. `tauri.conf.json` lives in `crates/desktop/` and references `../../apps/desktop/dist` for the frontend (which 2b creates). - -**Tech Stack:** Tauri 2, tauri-specta 2.0.0-rc.24, specta 2.0.0-rc.24, specta-typescript 0.0.11, tauri-build 2. Existing: rusqlite, blake3, walkdir, sysinfo, clap, rayon. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md` (section "Phase 2a exit criteria") - -**Execution rule:** All work on `main`. Per-commit: execute → `just ci` green → reviewer → commit. - ---- - -## File Structure - -``` -Cargo.toml # modify — add workspace members, deps -crates/desktop/ -├── Cargo.toml # new -├── build.rs # new — tauri-build + specta codegen -├── tauri.conf.json # new -├── capabilities/ -│ └── default.json # new — Tauri v2 permissions -├── icons/ # new — default Tauri icons (generated) -└── src/ - ├── lib.rs # new — Tauri plugin/app setup - ├── state.rs # new — AppState - └── commands.rs # new — #[tauri::command] fns -justfile # modify — add desktop targets -``` - -Two reviewer-gated commits: (1) crate scaffold + commands, (2) justfile + final sweep. - ---- - -## Task 1: Install Tauri CLI + scaffold empty crate - -**Files:** -- Modify: `Cargo.toml` (workspace members) -- Create: `crates/desktop/Cargo.toml` -- Create: `crates/desktop/build.rs` -- Create: `crates/desktop/src/lib.rs` - -The implementer should: - -1. Install `tauri-cli` if missing: `cargo install tauri-cli --locked`. -2. Add `crates/desktop` to workspace members. The workspace `Cargo.toml` already has `members = ["crates/*"]` which auto-discovers — but we need the new deps. Add to `[workspace.dependencies]`: - ```toml - specta = "=2.0.0-rc.24" - specta-typescript = "0.0.11" - tauri-build = "2" - ``` -3. Create `crates/desktop/Cargo.toml`: - ```toml - [package] - name = "perima-desktop" - version = "0.1.0" - edition.workspace = true - rust-version.workspace = true - license.workspace = true - repository.workspace = true - - [lib] - crate-type = ["staticlib", "cdylib", "rlib"] - name = "perima_desktop" - - [dependencies] - perima-core = { path = "../core" } - perima-db = { path = "../db" } - perima-fs = { path = "../fs" } - perima-hash = { path = "../hash" } - tauri.workspace = true - tauri-specta.workspace = true - specta.workspace = true - serde.workspace = true - serde_json.workspace = true - tracing.workspace = true - uuid.workspace = true - chrono.workspace = true - rayon.workspace = true - dunce.workspace = true - - [build-dependencies] - tauri-build.workspace = true - - [dev-dependencies] - tempfile.workspace = true - - [lints] - workspace = true - ``` -4. Create `crates/desktop/build.rs`: - ```rust - fn main() { - tauri_build::build(); - } - ``` -5. Create `crates/desktop/src/lib.rs` with a minimal placeholder: - ```rust - //! Tauri desktop backend for perima. - - pub mod commands; - pub mod state; - ``` -6. Create empty `crates/desktop/src/state.rs` and `crates/desktop/src/commands.rs` with module docstrings only. -7. Create `crates/desktop/tauri.conf.json` per the spec (with `"frontendDist": "../../apps/desktop/dist"`, `devUrl` at localhost:5173, dialog plugin, window config). -8. Create `crates/desktop/capabilities/default.json` per the spec. -9. Verify: `cargo build -p perima-desktop` exits 0. **NOTE:** the build will fail if `tauri.conf.json` references a `frontendDist` that doesn't exist — `tauri-build` checks this at compile time. Workaround: create the placeholder dir `mkdir -p apps/desktop/dist && touch apps/desktop/dist/index.html` so the build doesn't fail. 2b replaces this with the real Vite build output. - -- [ ] **Step 1: Install tauri-cli** -- [ ] **Step 2: Create all files above** -- [ ] **Step 3: Create placeholder `apps/desktop/dist/index.html`** -- [ ] **Step 4: Verify `cargo build -p perima-desktop`** -- [ ] **Step 5: Run `just ci`** — may need to add `perima-desktop` to CI awareness if clippy/test skips it. - ---- - -## Task 2: `AppState` (`crates/desktop/src/state.rs`) - -The implementer should create `state.rs`: - -```rust -//! Shared application state for Tauri commands. - -use perima_core::DeviceId; -use crate::config_path; - -/// State shared across all Tauri commands via `tauri::State`. -pub struct AppState { - /// Resolved data directory (where perima.db lives). - pub data_dir: std::path::PathBuf, - /// Stable device identifier. - pub device_id: DeviceId, -} -``` - -And a helper in `lib.rs` to construct it using `Config::resolve`: -- Import the CLI's `config` module... wait, the CLI's config is in `crates/cli/src/config.rs` which is a binary crate. The desktop crate can't import from it. -- **Solution:** extract config resolution into a shared location. Two options: - (a) Move `config.rs` to `crates/core/` (but core has zero framework deps — `directories` is a framework dep). - (b) Duplicate the config logic in `crates/desktop/`. - (c) Create a tiny `crates/config/` crate. - - **Best for v1:** duplicate. The config is ~40 lines. A shared crate is premature abstraction. Add a `config.rs` to `crates/desktop/src/` that mirrors the CLI's `Config::resolve` (using `directories` + env vars + `device_id.txt`). - -- [ ] **Step 1: Add `directories.workspace = true` to desktop Cargo.toml deps** -- [ ] **Step 2: Create `crates/desktop/src/config.rs`** — same logic as CLI's config (resolve data_dir, config_dir, device_id). Can be a simplified version since no `--data-dir` CLI flag in Tauri. -- [ ] **Step 3: Create `crates/desktop/src/state.rs`** with `AppState` struct. -- [ ] **Step 4: Update `lib.rs`** to add `pub mod config; pub mod state;`. -- [ ] **Step 5: Verify build** - ---- - -## Task 3: Tauri commands (`crates/desktop/src/commands.rs`) - -The implementer should create `commands.rs` with three `#[tauri::command]` functions: - -1. `scan(path: String, dry_run: bool, state: tauri::State) -> Result`: - - Opens DB via `open_and_migrate(state.data_dir.join("perima.db"))`. - - Constructs `WalkdirScanner`, `Blake3Service`, `SqliteFileRepository`, `SqliteVolumeRepository`. - - Calls the same scan logic from phase 1c (detect_volume, find_or_create, walk, hash, persist, manifest). - - Returns a `ScanResult { total: u64, new: u64, existing: u64, errors: u64 }` struct. - - Note: the scan logic currently lives as a function in `crates/cli/src/cmd/scan.rs` which is in the CLI binary crate. The desktop crate can't import from it either. - - **Solution:** The scan logic needs to be extracted to a shared crate or duplicated. Since `scan::run` has 8+ parameters and complex borrow patterns, the cleanest approach is to create a thin wrapper in `commands.rs` that replicates the scan flow (walk → hash → persist → manifest) directly. It's ~50 lines of glue. The core + adapters do the real work. - -2. `list_files(limit: u32, volume: Option, state: tauri::State) -> Result, String>`: - - Opens DB, constructs `SqliteFileRepository`, calls `list_file_locations`. - -3. `list_volumes(state: tauri::State) -> Result, String>`: - - Opens DB, constructs `SqliteVolumeRepository`, calls `list(device_id)`. - -All commands map `CoreError` → `String` via `.map_err(|e| e.to_string())` (Tauri commands require `Result` for IPC serialization). - -Define a `ScanResult` struct with `#[derive(Serialize, specta::Type)]`. - -- [ ] **Step 1: Define `ScanResult` struct** -- [ ] **Step 2: Implement `list_files` command (simplest)** -- [ ] **Step 3: Implement `list_volumes` command** -- [ ] **Step 4: Implement `scan` command (most complex — walk + hash + persist + manifest)** -- [ ] **Step 5: Verify build** - ---- - -## Task 4: Tauri app setup (`crates/desktop/src/lib.rs`) - -The implementer should wire `lib.rs` to register commands and manage state: - -```rust -//! Tauri desktop backend for perima. - -pub mod commands; -pub mod config; -pub mod state; - -use tauri::Manager; - -/// Build and run the Tauri application. -/// -/// # Errors -/// Returns a `tauri::Error` if the app fails to initialize. -pub fn run() -> Result<(), tauri::Error> { - let config = config::resolve_config() - .expect("failed to resolve config"); - - let app_state = state::AppState { - data_dir: config.data_dir, - device_id: config.device_id, - }; - - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .manage(app_state) - .invoke_handler(tauri::generate_handler![ - commands::scan, - commands::list_files, - commands::list_volumes, - ]) - .run(tauri::generate_context!()) -} -``` - -Note: `tauri_plugin_dialog` needs to be added as a dependency. Add `tauri-plugin-dialog = "2"` to workspace deps and `crates/desktop/Cargo.toml`. - -Also, the `tauri::generate_context!()` macro reads `tauri.conf.json` relative to the crate — verify the path works. - -- [ ] **Step 1: Add `tauri-plugin-dialog` dep** -- [ ] **Step 2: Write `lib.rs` with app builder** -- [ ] **Step 3: Verify build** - -- [ ] **Step 4: Dispatch reviewer (checkpoint #1)** - -Reviewer checklist: -- [ ] `cargo build -p perima-desktop` exits 0. -- [ ] `cargo clippy -p perima-desktop -- -D warnings` exits 0. -- [ ] Commands are `#[tauri::command]` annotated. -- [ ] `AppState` holds data_dir + device_id. -- [ ] Config resolution duplicated from CLI (acceptable for v1). -- [ ] `tauri.conf.json` has correct frontendDist path. -- [ ] `just ci` green. - -- [ ] **Step 5: Commit** - -```bash -git add Cargo.toml Cargo.lock crates/desktop/ apps/desktop/dist/index.html justfile -git commit -m "$(cat <<'EOF' -feat(phase-2a): Tauri backend crate with scan/list/volumes commands - -crates/desktop: Tauri v2 backend crate with three #[tauri::command] -functions (scan, list_files, list_volumes) via tauri-specta for -type-safe IPC. AppState holds resolved Config + DeviceId. Each -command opens its own DB connection (cheap under WAL). Config -resolution duplicated from CLI (shared crate deferred to avoid -premature abstraction). - -tauri.conf.json: window config, dialog plugin, frontendDist at -../../apps/desktop/dist. Placeholder index.html until phase 2b -ships the real React build. - -Refs: docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 5: Headless command tests - -**Files:** -- Create: `crates/desktop/tests/commands_test.rs` - -The implementer should write integration tests that invoke the Tauri commands directly (without spawning a window). Approach: - -Since `tauri::test` utilities in v2 are still experimental, the simplest approach is to test the command functions directly as regular Rust functions by constructing a mock `tauri::State`. If that's not possible due to Tauri's `State` being tied to the app lifecycle, test the underlying logic instead: - -1. Create a tmpdir fixture with 3 files. -2. Construct `AppState` with `data_dir` pointing to the tmpdir. -3. Call the command functions with the constructed state. -4. Assert results. - -If `tauri::State` cannot be constructed outside an app, fall back to testing the glue functions that the commands delegate to (extract the DB-opening + core-calling logic into testable non-Tauri functions, then test those). - -3 tests: -- `scan_command_indexes_files` — scan a tmpdir → ScanResult with total=3, new=3. -- `list_files_returns_indexed` — after scan, list_files returns 3 records. -- `list_volumes_returns_one` — after scan, list_volumes returns ≥1 volume. - -- [ ] **Step 1: Write tests (adapting to whatever approach works with tauri::State)** -- [ ] **Step 2: Run tests** -- [ ] **Step 3: Verify `just ci` green** - ---- - -## Task 6: Update `justfile` + final sweep - -**Files:** -- Modify: `justfile` - -Add desktop-specific targets: - -```just -build-desktop: - cargo build -p perima-desktop - -clippy-desktop: - cargo clippy -p perima-desktop -- -D warnings -``` - -The `ci` target already runs `cargo clippy --workspace --all-targets` which covers `perima-desktop`. No change needed to `ci`. But verify this is true. - -- [ ] **Step 1: Verify `just ci` includes desktop crate in all targets** -- [ ] **Step 2: WHY-comment check** - -Run: `grep -rE '^\s*//\s*WHY:' crates/desktop/ | wc -l` -Expected: ≥ 1 (per-command DB connection rationale). - -- [ ] **Step 3: Clean build** - -Run: `cargo clean && just ci` - -- [ ] **Step 4: Dispatch reviewer (checkpoint #2)** - -Reviewer checklist: -- [ ] All P2a exit criteria met (P2a-1 through P2a-6). -- [ ] Headless tests pass. -- [ ] WHY comments present. -- [ ] `just ci` green. - -- [ ] **Step 5: Commit tests** - -```bash -git add crates/desktop/tests/ justfile Cargo.lock -git commit -m "$(cat <<'EOF' -test(phase-2a): headless Tauri command tests - -Headless tests for scan/list_files/list_volumes commands against -tmpdir fixtures. Tests construct AppState directly and invoke -command logic without spawning a Tauri window. - -Refs: docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 6: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -Note: Do NOT tag yet — phase 2 completes after 2b. No `phase-2a-complete` tag (unlike 1a/1b/1c which were sub-phases of a single meta-plan phase; 2a/2b are just plan splits within one phase). - ---- - -## Self-review - -**Spec coverage (2a exit criteria):** -- P2a-1: `cargo build -p perima-desktop` → Tasks 1-4 ✓ -- P2a-2: Headless command tests → Task 5 ✓ -- P2a-3: `cargo clippy -p perima-desktop -- -D warnings` → Task 6 ✓ -- P2a-4: `just ci` green → Task 6 ✓ -- P2a-5: `tauri.conf.json` present → Task 1 ✓ -- P2a-6: specta version resolved → Task 1 (pins at rc.24) ✓ - -**Placeholder scan:** Tasks 2-5 describe requirements rather than verbatim code because Tauri v2's API surface (especially `tauri::test`, `tauri::State` construction, and specta codegen) has enough churn that prescribing exact code would produce compile errors. The requirements are specific enough for a skilled Rust developer to implement. This is the same approach used successfully in phase 1c Tasks 2-4. - -**Type consistency:** `ScanResult` matches between commands.rs definition and test assertions. `AppState` matches between state.rs, lib.rs, and commands.rs. `FileLocationRecord` and `VolumeRecord` are re-used from `perima-core`. - -**Commit discipline:** Two reviewer-gated commits matching the checkpoint pattern. diff --git a/docs/superpowers/plans/2026-04-16-phase-2b-react-frontend.md b/docs/superpowers/plans/2026-04-16-phase-2b-react-frontend.md deleted file mode 100644 index afc86c2..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-2b-react-frontend.md +++ /dev/null @@ -1,329 +0,0 @@ -# Phase 2b — React Frontend Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship the React + Vite + Tailwind frontend in `apps/desktop/` with FileTable, ScanButton, StatusBar components, `neverthrow`-wrapped Tauri IPC, vitest component tests, and ESLint + TSDoc validation. - -**Architecture:** Single-page React app using `@tauri-apps/api` for IPC. `api.ts` wraps `invoke` in `neverthrow` `ResultAsync`. Three components (FileTable, ScanButton, StatusBar) receive data via props from `App.tsx` which manages state. vitest + jsdom + @testing-library/react for component tests with mocked Tauri modules. - -**Tech Stack:** Bun, Vite 6, React 19, Tailwind CSS 4 (@tailwindcss/vite), TypeScript 5 strict, neverthrow, vitest 3, jsdom, @testing-library/react, ESLint 9 flat config + eslint-plugin-tsdoc. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-2b-react-frontend-design.md` - -**Execution rule:** All work on `main`. Bun only. Per-commit: execute → `just ci` green → reviewer → commit. - ---- - -## File Structure - -All files in `apps/desktop/` unless noted. - -``` -apps/desktop/ -├── index.html # modify (replace placeholder) -├── package.json # new -├── bun.lock # generated by bun install -├── vite.config.ts # new -├── tsconfig.json # new -├── eslint.config.js # new -├── src/ -│ ├── main.tsx # new -│ ├── App.tsx # new -│ ├── App.css # new -│ ├── types.ts # new -│ ├── api.ts # new -│ ├── components/ -│ │ ├── FileTable.tsx # new -│ │ ├── ScanButton.tsx # new -│ │ └── StatusBar.tsx # new -│ └── __tests__/ -│ ├── setup.ts # new — vitest global mocks -│ ├── FileTable.test.tsx # new -│ ├── ScanButton.test.tsx # new -│ └── StatusBar.test.tsx # new - -justfile # modify — add frontend targets -.github/workflows/ci.yml # modify — add bun setup -.gitignore # modify — add node_modules, dist -``` - -Two reviewer-gated commits: (1) scaffold + components + tests, (2) CI + justfile + final sweep. - ---- - -## Task 1: Scaffold Vite + React + Tailwind project - -The implementer should: - -1. Navigate to `apps/desktop/`. -2. Create `package.json` with: - - `name: "perima-desktop"` - - `private: true` - - `type: "module"` - - dependencies: `react`, `react-dom`, `@tauri-apps/api@^2`, `@tauri-apps/plugin-dialog@^2`, `neverthrow@^8` - - devDependencies: `vite@^6`, `@vitejs/plugin-react@^4`, `typescript@^5`, `@tailwindcss/vite@^4`, `tailwindcss@^4`, `vitest@^3`, `jsdom@^26`, `@testing-library/react@^16`, `@testing-library/jest-dom@^6`, `eslint@^9`, `eslint-plugin-tsdoc@^0.4`, `@types/react@^19`, `@types/react-dom@^19` - - scripts: `"dev": "vite"`, `"build": "tsc -b && vite build"`, `"test": "vitest run"`, `"lint": "eslint src/"`, `"preview": "vite preview"` -3. Run `bun install` to generate `bun.lock` + `node_modules/`. -4. Create `vite.config.ts` per spec (React + Tailwind plugins, port 5173 pinned, strictPort). -5. Create `tsconfig.json` with strict mode, JSX react-jsx, target ES2022, module ESNext, moduleResolution bundler. -6. Replace `index.html` placeholder with a proper Vite entry: - ```html - - - - - - perima - - -
- - - - ``` -7. Create `src/main.tsx`: - ```tsx - import { StrictMode } from "react"; - import { createRoot } from "react-dom/client"; - import App from "./App"; - import "./App.css"; - - createRoot(document.getElementById("root")!).render( - - - , - ); - ``` -8. Create `src/App.css` with Tailwind import: - ```css - @import "tailwindcss"; - ``` -9. Create a minimal `src/App.tsx` placeholder: - ```tsx - export default function App() { - return
perima loading...
; - } - ``` -10. Verify: `bun run build` exits 0. -11. Update `.gitignore` to add `node_modules/` and `apps/desktop/dist/` (but keep the committed placeholder — actually the build output replaces it; we want dist gitignored now that we have a real build). - -- [ ] **Step 1: Create all scaffold files** -- [ ] **Step 2: `bun install` → verify `bun.lock` generated** -- [ ] **Step 3: `bun run build` → exits 0** - ---- - -## Task 2: `types.ts` + `api.ts` - -- [ ] **Step 1: Create `src/types.ts`** with `ScanResult`, `FileEntry`, `VolumeEntry` interfaces per spec. -- [ ] **Step 2: Create `src/api.ts`** with `neverthrow` `ResultAsync` wrappers per spec. Note: use `fromPromise` from `neverthrow` to wrap `invoke`. -- [ ] **Step 3: Verify build** — `bun run build` exits 0. - ---- - -## Task 3: `FileTable.tsx` + test (TDD) - -- [ ] **Step 1: Create vitest setup** at `src/__tests__/setup.ts`: - ```typescript - import { vi } from "vitest"; - - // Mock Tauri core API globally for all tests. - vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn(), - })); - - // Mock Tauri dialog plugin globally. - vi.mock("@tauri-apps/plugin-dialog", () => ({ - open: vi.fn(), - })); - ``` - Add `setupFiles: ["src/__tests__/setup.ts"]` to vitest config in `vite.config.ts`: - ```typescript - test: { - environment: "jsdom", - setupFiles: ["src/__tests__/setup.ts"], - globals: true, - }, - ``` - -- [ ] **Step 2: Write `FileTable.test.tsx`** with 3 tests: - - Renders 3 data rows for 3 FileEntry items. - - Shows "No files indexed yet" when empty. - - Shows loading indicator when `loading=true`. - -- [ ] **Step 3: Create `src/components/FileTable.tsx`** implementing the component per spec (table with HASH/SIZE/VOLUME/PATH/STATUS columns, client-side sort, empty/loading states). - -- [ ] **Step 4: Run `bun run test`** → FileTable tests pass. - ---- - -## Task 4: `ScanButton.tsx` + test (TDD) - -- [ ] **Step 1: Write `ScanButton.test.tsx`** with tests: - - Renders "Scan Folder" button. - - On click: calls dialog `open`, then `api.scan`, then `onScanComplete`. - - Shows "Scanning..." while scanning (disabled state). - -- [ ] **Step 2: Create `src/components/ScanButton.tsx`** per spec. Uses `@tauri-apps/plugin-dialog`'s `open({ directory: true })` for folder picker. - -- [ ] **Step 3: Run `bun run test`** → ScanButton tests pass. - ---- - -## Task 5: `StatusBar.tsx` + test (TDD) - -- [ ] **Step 1: Write `StatusBar.test.tsx`** with tests: - - Shows summary when `scanResult` present. - - Shows error in red when `error` present. - - Shows "No scans yet" when both null. - -- [ ] **Step 2: Create `src/components/StatusBar.tsx`** per spec. - -- [ ] **Step 3: Run `bun run test`** → StatusBar tests pass. - ---- - -## Task 6: Wire `App.tsx` - -- [ ] **Step 1: Rewrite `App.tsx`** to compose FileTable, ScanButton, StatusBar with state management: - - State: `files`, `scanResult`, `scanning`, `error`. - - On mount: `listFiles(100).match(...)`. - - ScanButton's `onScanComplete` refreshes the file list. - - Error handling via `neverthrow` `.match()`. - -- [ ] **Step 2: Run `bun run test`** → all 9+ tests pass. -- [ ] **Step 3: Run `bun run build`** → exits 0. - ---- - -## Task 7: ESLint config - -- [ ] **Step 1: Create `eslint.config.js`** — ESLint 9 flat config with TypeScript parser + `eslint-plugin-tsdoc`. -- [ ] **Step 2: Run `bun run lint`** → exits 0. Fix any lint violations. - ---- - -## Task 8: Justfile + CI + `.gitignore` updates - -- [ ] **Step 1: Update `.gitignore`** — add `node_modules/` line. Change `apps/desktop/dist/` handling: the dist is now built by Vite and shouldn't be committed. But `apps/desktop/dist/index.html` was committed as a placeholder in 2a. Remove it from git tracking: - ```bash - git rm --cached apps/desktop/dist/index.html - ``` - Then add `apps/desktop/dist/` to `.gitignore`. - -Wait — `.gitignore` already has `**/*.md` but not `node_modules/` or `dist/`. The current `.gitignore`: -``` -**/*.md -target/ -.DS_Store -*.swp -``` - -Add: -``` -node_modules/ -apps/desktop/dist/ -``` - -- [ ] **Step 2: Update `justfile`** — add frontend targets + include them in `ci`: - ```just - build-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run build - - test-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run test - - lint-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run lint - - ci: fmt-check clippy test doctest docs-coverage build-frontend test-frontend lint-frontend - ``` - -- [ ] **Step 3: Update `.github/workflows/ci.yml`** — add Bun setup step before `just ci`: - ```yaml - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - ``` - -- [ ] **Step 4: Run `just ci`** — full pipeline including frontend. - -- [ ] **Step 5: Dispatch reviewer (checkpoint #1)** - -Reviewer checklist: -- [ ] `bun run build` exits 0 with real Vite output. -- [ ] `bun run test` exits 0 with 9+ component tests. -- [ ] `bun run lint` exits 0. -- [ ] `just ci` green (all Rust + frontend gates). -- [ ] `neverthrow` used in api.ts. -- [ ] Tauri API mocked in tests. -- [ ] `.gitignore` updated. `node_modules/` not tracked. -- [ ] CI has `oven-sh/setup-bun@v2`. - -- [ ] **Step 6: Commit** - -```bash -git rm --cached apps/desktop/dist/index.html -git add apps/desktop/ justfile .github/workflows/ci.yml .gitignore Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-2b): React frontend with FileTable, ScanButton, StatusBar - -apps/desktop: Vite 6 + React 19 + Tailwind 4 + TypeScript 5 strict. -Single page with file table, folder-picker scan trigger, status bar. -neverthrow ResultAsync wraps Tauri IPC for typed error handling. - -Components: FileTable (sortable columns, empty/loading states), -ScanButton (native dialog, disabled while scanning), StatusBar -(scan summary / error / "No scans yet"). - -9+ vitest component tests with mocked @tauri-apps/api and -@tauri-apps/plugin-dialog. ESLint 9 flat config + tsdoc plugin. - -CI: oven-sh/setup-bun@v2 on all runners. justfile gains -build-frontend, test-frontend, lint-frontend targets in ci pipeline. - -Refs: docs/superpowers/specs/2026-04-16-phase-2b-react-frontend-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 9: Final sweep + push + tag - -- [ ] **Step 1: GH issue audit** — review DECISIONS.md + this phase for deferrals not in the meta-plan. Create GH issues for any found. - -- [ ] **Step 2: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -- [ ] **Step 3: Tag (after CI green)** - -```bash -git tag -a phase-2-complete -m "Phase 2: Tauri desktop shell (backend + React frontend)" -git push origin phase-2-complete -``` - ---- - -## Self-review - -**Spec coverage:** -- Vite + React + Tailwind scaffold → Task 1 ✓ -- types.ts + api.ts (neverthrow) → Task 2 ✓ -- FileTable + test → Task 3 ✓ -- ScanButton + test → Task 4 ✓ -- StatusBar + test → Task 5 ✓ -- App.tsx wiring → Task 6 ✓ -- ESLint + tsdoc → Task 7 ✓ -- Justfile + CI + gitignore → Task 8 ✓ -- Exit criteria P2b-1..6 → Task 9 ✓ - -**Placeholder scan:** Tasks describe requirements with enough specificity for a skilled TS/React developer. No "implement later" or "add appropriate styling." - -**Type consistency:** `ScanResult`, `FileEntry`, `VolumeEntry` match between types.ts, api.ts, and component props. `ResultAsync` used consistently in api.ts. Component prop types match between App.tsx state and child components. diff --git a/docs/superpowers/plans/2026-04-16-phase-3a-watcher-eventbus-cli.md b/docs/superpowers/plans/2026-04-16-phase-3a-watcher-eventbus-cli.md deleted file mode 100644 index 9306f4a..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-3a-watcher-eventbus-cli.md +++ /dev/null @@ -1,488 +0,0 @@ -# Phase 3a — EventBus + Watcher + CLI Watch Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add filesystem watching to perima: `EventBus` trait in core, `DebouncedWatcher` in fs crate via `notify-debouncer-full`, DB status updates (`Stale`/`Missing`/`Moved`), `perima watch ` CLI command, and migration from `AtomicBool` cancellation to `tokio-util::CancellationToken`. Tokio runtime introduced for the CLI. - -**Architecture:** `crates/core` gains `events.rs` (FileEvent enum + EventBus trait) and a `Stale` variant on `LocationStatus`. `crates/fs` gains `watcher.rs` wrapping `notify-debouncer-full` with a tokio background task that maps debounced events to `FileEvent`s and calls `EventBus::emit`. `crates/db` gains `update_location_status` + `update_location_path` methods on `SqliteFileRepository`. `crates/cli` gains `cmd/watch.rs` and switches `main` to `#[tokio::main]`. - -**Tech Stack:** tokio (already in workspace), tokio-util (new — `CancellationToken`), notify-debouncer-full 0.7, file-id 0.2 (both already pinned). Existing: rusqlite, blake3, walkdir, sysinfo, clap, rayon, ctrlc. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-3-watching-design.md` - -**Execution rule:** All work on `main`. Per-commit: execute → `just ci` green → reviewer → commit. - ---- - -## File Structure - -``` -Cargo.toml # modify — add tokio-util workspace dep -crates/core/src/ -├── types.rs # modify — add Stale to LocationStatus -├── events.rs # new — FileEvent + EventBus trait -└── lib.rs # modify — add events module + re-exports - -crates/fs/ -├── Cargo.toml # modify — add notify-debouncer-full, file-id, tokio, tokio-util -└── src/ - ├── watcher.rs # new — DebouncedWatcher - └── lib.rs # modify — add watcher module - -crates/db/src/ -├── file_repo.rs # modify — add update_location_status, update_location_path - -crates/cli/ -├── Cargo.toml # modify — add tokio, tokio-util -└── src/ - ├── signals.rs # modify — migrate to CancellationToken - ├── cmd/ - │ ├── mod.rs # modify — add watch module - │ ├── scan.rs # modify — use CancellationToken - │ └── watch.rs # new — perima watch command - └── main.rs # modify — add Watch command, #[tokio::main] -``` - -Three reviewer-gated commits: (1) core events + Stale + DB methods, (2) watcher + cancellation migration, (3) CLI watch + tests. - ---- - -## Task 1: Add `Stale` to `LocationStatus` + workspace deps - -**Files:** -- Modify: `crates/core/src/types.rs` -- Modify: `Cargo.toml` (workspace deps) - -The implementer should: - -1. Add `Stale` variant to `LocationStatus` in `crates/core/src/types.rs`: - ```rust - pub enum LocationStatus { - Active, - Missing, - Moved, - /// Hash is outdated — file was modified in place. Next scan - /// will re-hash and restore to Active. - Stale, - } - ``` - -2. Update the DB deserializer in `crates/db/src/file_repo.rs` — the `list_file_locations` method has a `match status_str.as_str()` block. Add `"stale" => LocationStatus::Stale`. - -3. Add `tokio-util` to workspace `[workspace.dependencies]`: - ```toml - tokio-util = { version = "0.7", features = ["rt"] } - ``` - -4. Verify: `just ci` green. - ---- - -## Task 2: `FileEvent` enum + `EventBus` trait - -**Files:** -- Create: `crates/core/src/events.rs` -- Modify: `crates/core/src/lib.rs` - -The implementer should create `events.rs`: - -```rust -//! Filesystem event types and the `EventBus` trait. - -use serde::Serialize; - -use crate::{CoreError, MediaPath, VolumeId}; - -/// A filesystem event detected by the watcher. -#[derive(Clone, Debug, Serialize)] -pub enum FileEvent { - /// A new file appeared at this path. - Created { - /// Relative path within the volume. - path: MediaPath, - /// Volume the file lives on. - volume: VolumeId, - }, - /// An existing file's content was modified. - Modified { - /// Relative path within the volume. - path: MediaPath, - /// Volume the file lives on. - volume: VolumeId, - }, - /// A file was deleted from this path. - Deleted { - /// Relative path within the volume. - path: MediaPath, - /// Volume the file lives on. - volume: VolumeId, - }, - /// A file was renamed/moved within the same volume. - Renamed { - /// Previous relative path. - from: MediaPath, - /// New relative path. - to: MediaPath, - /// Volume the file lives on. - volume: VolumeId, - }, -} - -/// Consumer of filesystem events. -/// -/// Multiple implementations can be composed via a fan-out adapter -/// (e.g., `CompositeEventBus`). The composite logs errors from -/// individual handlers but does not abort — remaining handlers -/// still fire. -pub trait EventBus: Send + Sync { - /// Process an event. - /// - /// # Errors - /// Returns `CoreError` if the handler fails (e.g., DB write). - fn emit(&self, event: &FileEvent) -> Result<(), CoreError>; -} -``` - -Update `lib.rs` to add `pub mod events;` and re-export `FileEvent` + `EventBus`. - -Verify: `just ci` green. - ---- - -## Task 3: DB status update methods (TDD) - -**Files:** -- Modify: `crates/db/src/file_repo.rs` - -The implementer should add two new methods to `SqliteFileRepository` (NOT on the `FileRepository` trait — these are impl-specific like `migrate_sentinel_row`): - -1. `update_location_status(&self, volume: VolumeId, path: &MediaPath, status: LocationStatus, device: DeviceId) -> Result` — updates the status of the matching active (non-deleted) file_location row. Returns rows affected. - -2. `update_location_path(&self, volume: VolumeId, old_path: &MediaPath, new_path: &MediaPath, device: DeviceId) -> Result` — updates `relative_path` and sets `status = 'active'` for the matching row. For renames. - -Write 4 tests (TDD): -- `update_status_to_missing` — set a location to Missing. -- `update_status_to_stale` — set a location to Stale. -- `update_location_path_renames` — rename a location, verify new path + Active status. -- `update_location_path_nonexistent` — rename a path that doesn't exist → 0 rows affected. - -- [ ] **Step 1: Write tests first** -- [ ] **Step 2: Implement methods** -- [ ] **Step 3: Run gates** — `cargo test -p perima-db` (26+ tests). - -- [ ] **Step 4: Dispatch reviewer (checkpoint #1)** - -Reviewer checklist: -- [ ] `Stale` variant in `LocationStatus`. -- [ ] DB deserializer handles `"stale"`. -- [ ] `FileEvent` enum + `EventBus` trait in core. -- [ ] `update_location_status` + `update_location_path` on `SqliteFileRepository`. -- [ ] 4 new tests pass. `just ci` green. - -- [ ] **Step 5: Commit** - -```bash -git add Cargo.toml Cargo.lock crates/core/ crates/db/src/file_repo.rs -git commit -m "$(cat <<'EOF' -feat(phase-3a): FileEvent + EventBus trait, Stale status, DB status methods - -crates/core: FileEvent enum (Created/Modified/Deleted/Renamed) + -EventBus trait with fallible emit(). LocationStatus gains Stale -variant (hash outdated, next scan re-hashes). - -crates/db: SqliteFileRepository gains update_location_status and -update_location_path for watcher-driven status transitions. -DB deserializer handles "stale" string. - -tokio-util added to workspace deps (CancellationToken for phase 3a -watcher + cancellation migration). - -Refs: docs/superpowers/specs/2026-04-16-phase-3-watching-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 4: `DebouncedWatcher` (`crates/fs/src/watcher.rs`) - -**Files:** -- Modify: `crates/fs/Cargo.toml` — add `notify-debouncer-full`, `file-id`, `tokio`, `tokio-util` -- Create: `crates/fs/src/watcher.rs` -- Modify: `crates/fs/src/lib.rs` - -The implementer should: - -1. Add deps to `crates/fs/Cargo.toml`: - ```toml - notify-debouncer-full.workspace = true - file-id.workspace = true - tokio.workspace = true - tokio-util.workspace = true - ``` - -2. Create `watcher.rs` with: - - `pub struct DebouncedWatcher` holding the debouncer handle. - - `pub fn start(paths: &[PathBuf], volume_root: &Path, volume_id: VolumeId, bus: Arc, cancel: CancellationToken) -> Result`. - - Inside `start`: create a `notify_debouncer_full::new_debouncer` with a 1-second timeout. The debouncer's event handler receives `Result>`. Spawn a tokio task that: - - Receives events from the debouncer's channel. - - Maps each `DebouncedEvent` to a `FileEvent` using the event's `kind` + `paths`. - - For renames: `notify` emits `EventKind::Modify(ModifyKind::Name(RenameMode::Both))` with `paths[0]` = old, `paths[1]` = new. - - Calls `bus.emit(&file_event)` for each. - - Checks `cancel.is_cancelled()` between events. - - Dropping `DebouncedWatcher` stops the debouncer. - -3. Write 3 tests (timing-sensitive; use 100ms debounce for tests): - - `watcher_detects_create` — create a file → assert `FileEvent::Created` emitted. - - `watcher_detects_delete` — delete a file → `FileEvent::Deleted`. - - `watcher_detects_rename` — rename a file → `FileEvent::Renamed`. - - Tests use a `MockEventBus` that collects events into `Arc>>`. Wait up to 3 seconds for the expected event count. - - **Important:** these tests require a tokio runtime. Use `#[tokio::test]`. - -4. Update `crates/fs/src/lib.rs` — add `pub mod watcher;` + `pub use watcher::DebouncedWatcher;`. - -- [ ] **Step 1: Add deps** -- [ ] **Step 2: Write `watcher.rs` with tests** -- [ ] **Step 3: Run gates** — may be flaky; retry once if timing-related. - ---- - -## Task 5: Migrate cancellation to `CancellationToken` - -**Files:** -- Modify: `crates/cli/Cargo.toml` — add `tokio.workspace = true`, `tokio-util.workspace = true` -- Modify: `crates/cli/src/signals.rs` -- Modify: `crates/cli/src/cmd/scan.rs` -- Modify: `crates/cli/src/main.rs` - -The implementer should: - -1. Rewrite `signals.rs`: replace `AtomicBool` with `tokio_util::sync::CancellationToken`. - ```rust - pub struct Cancellation { - token: CancellationToken, - } - impl Cancellation { - pub fn cancelled(&self) -> bool { self.token.is_cancelled() } - pub fn token(&self) -> CancellationToken { self.token.clone() } - } - pub fn install() -> Result { - let token = CancellationToken::new(); - let cloned = token.clone(); - ctrlc::set_handler(move || { cloned.cancel(); }) - .map_err(|e| CoreError::Internal(format!("ctrlc: {e}")))?; - Ok(Cancellation { token }) - } - ``` - -2. Update `scan.rs`: the `cancel.token()` calls now return `CancellationToken` instead of `Arc`. Inside the rayon map closure, use `cancel_token.is_cancelled()` instead of `cancel_flag.load(Ordering::SeqCst)`. The `take_while` in the walk uses `cancel.cancelled()` which still works. - -3. Update `main.rs`: add `#[tokio::main]` and make `main` async. Existing sync commands run directly (no `spawn_blocking` needed). Add `tokio` + `tokio-util` to cli Cargo.toml deps. - -4. Verify: all existing tests pass. The cancellation unit test in `signals.rs` updates to use `CancellationToken` semantics. - -- [ ] **Step 1: Rewrite signals.rs** -- [ ] **Step 2: Update scan.rs** -- [ ] **Step 3: Update main.rs to `#[tokio::main] async fn main()`** -- [ ] **Step 4: Run gates** - -- [ ] **Step 5: Dispatch reviewer (checkpoint #2)** - -Reviewer checklist: -- [ ] `CancellationToken` replaces `AtomicBool` everywhere. -- [ ] `ctrlc` handler calls `token.cancel()`. -- [ ] `scan.rs` rayon closure uses `is_cancelled()`. -- [ ] `main.rs` is `#[tokio::main] async fn main()`. -- [ ] All existing tests pass (76 Rust + 9 TS). -- [ ] `just ci` green. - -- [ ] **Step 6: Commit** - -```bash -git add crates/fs/ crates/cli/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-3a): DebouncedWatcher + CancellationToken migration - -crates/fs/watcher.rs: DebouncedWatcher wraps notify-debouncer-full -with a tokio background task mapping DebouncedEvents to FileEvents -and emitting via EventBus. 3 watcher tests (create/delete/rename). - -crates/cli: signals.rs migrated from AtomicBool to tokio-util -CancellationToken. ctrlc handler calls token.cancel(). scan.rs -updated to use is_cancelled(). main.rs now #[tokio::main] async. -All existing sync commands unchanged — they complete on the main -task without yielding. - -Refs: docs/superpowers/specs/2026-04-16-phase-3-watching-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 6: `perima watch ` CLI command - -**Files:** -- Create: `crates/cli/src/cmd/watch.rs` -- Modify: `crates/cli/src/cmd/mod.rs` -- Modify: `crates/cli/src/main.rs` - -The implementer should: - -1. Create `watch.rs`: - ```rust - pub async fn run( - data_dir: &Path, - device_id: DeviceId, - path: &Path, - cancel: &Cancellation, - ) -> Result<(), CoreError> - ``` - - Validate path (exists, is dir). - - Canonicalize via dunce. - - Detect volume. - - Open DB, find_or_create volume, record mount. - - Create a `DbEventHandler` that implements `EventBus` by calling `update_location_status` / `update_location_path` on the file repo. - - Create a `LogEventHandler` that logs each event. - - Compose into a `CompositeEventBus`. - - Start `DebouncedWatcher` with the composed bus. - - `cancel.token().cancelled().await` — blocks until Ctrl-C. - - Print summary to stderr. - -2. The `DbEventHandler` needs access to `SqliteFileRepository`. Since it implements `EventBus: Send + Sync` and `SqliteFileRepository` uses `Mutex`, it can hold an `Arc`. - -3. The `CompositeEventBus`: - ```rust - pub struct CompositeEventBus { - handlers: Vec>, - } - impl EventBus for CompositeEventBus { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - for h in &self.handlers { - if let Err(e) = h.emit(event) { - tracing::warn!(error = %e, "event handler failed"); - } - } - Ok(()) - } - } - ``` - This can live in `crates/core/src/events.rs` or in `watch.rs`. - -4. Update `cmd/mod.rs` — add `pub mod watch;`. -5. Update `main.rs` — add `Watch { root: PathBuf }` variant. Dispatch to `cmd::watch::run`. - -- [ ] **Step 1: Create watch.rs + event handlers** -- [ ] **Step 2: Update mod.rs + main.rs** -- [ ] **Step 3: Run gates** — `cargo run -p perima -- watch /tmp` should start watching and log events until Ctrl-C. - ---- - -## Task 7: Integration test + final sweep - -**Files:** -- Create: `crates/cli/tests/watch_integration.rs` - -The implementer should: - -1. Create a `#[cfg(unix)]` integration test: - - Start `perima watch ` as a child process via `Command::new(bin())`. - - Wait 1 second for the watcher to initialize. - - Create a new file in the tmpdir. - - Wait 2 seconds for event processing. - - Send SIGTERM to the child (`nix::sys::signal::kill` or `libc::kill`). - - Capture stderr. - - Assert stderr mentions the watch summary or event count. - - **Alternative if signal handling is too complex:** just test that `perima watch ` starts and exits 130 when killed. - -2. GH issue audit: review DECISIONS.md + phase 3 spec for deferrals. - -3. WHY-comment check: `grep -rE '^\s*//\s*WHY:' crates/` ≥ previous count + new. - -4. `cargo clean && just ci` green. - -- [ ] **Step 1: Write integration test** -- [ ] **Step 2: GH issue audit** -- [ ] **Step 3: Final sweep** - -- [ ] **Step 4: Dispatch reviewer (checkpoint #3)** - -Reviewer checklist: -- [ ] `perima watch ` runs, detects creates, logs events. -- [ ] Ctrl-C exits 130 with summary. -- [ ] `update_location_status` called on Delete (→ Missing). -- [ ] `update_location_status` called on Modified (→ Stale). -- [ ] `update_location_path` called on Rename. -- [ ] Integration test passes on Linux/macOS. -- [ ] All 76+ previous tests pass. -- [ ] `just ci` green. -- [ ] GH issues created for deferrals. - -- [ ] **Step 5: Commit** - -```bash -git add crates/cli/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-3a): perima watch command + integration test - -crates/cli/cmd/watch.rs: async watch command that starts -DebouncedWatcher, composes DbEventHandler + LogEventHandler via -CompositeEventBus, runs until Ctrl-C (CancellationToken), prints -summary. - -DbEventHandler: Created → hash + upsert, Modified → status=stale, -Deleted → status=missing, Renamed → update_location_path. -CompositeEventBus fans out to all handlers, logs individual errors. - -Integration test (unix only): starts watch as child, creates a file, -kills child, asserts exit 130. - -Refs: docs/superpowers/specs/2026-04-16-phase-3-watching-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 6: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -Note: Do NOT tag yet — phase 3 completes after 3b (Tauri events + frontend). - ---- - -## Self-review - -**Spec coverage (3a scope):** -- FileEvent enum → Task 2 ✓ -- EventBus trait with fallible emit → Task 2 ✓ -- Stale LocationStatus variant → Task 1 ✓ -- DB status update methods → Task 3 ✓ -- DebouncedWatcher (notify-debouncer-full + tokio) → Task 4 ✓ -- CancellationToken migration → Task 5 ✓ -- `perima watch ` CLI → Task 6 ✓ -- CompositeEventBus → Task 6 ✓ -- DbEventHandler + LogEventHandler → Task 6 ✓ -- tokio runtime in CLI → Task 5 ✓ -- Integration test → Task 7 ✓ - -**NOT in 3a scope (deferred to 3b):** -- Tauri event emission -- Frontend live refresh with debounce - -**Placeholder scan:** Tasks describe requirements with enough specificity. No "implement later." - -**Type consistency:** `FileEvent` matches between core/events.rs, watcher.rs, and watch.rs. `CancellationToken` used consistently after migration. `LocationStatus::Stale` matches between types.rs, file_repo.rs deserializer, and watch.rs handler. - -**Commit discipline:** three reviewer-gated commits matching the checkpoint pattern. diff --git a/docs/superpowers/plans/2026-04-16-phase-3b-tauri-events-frontend.md b/docs/superpowers/plans/2026-04-16-phase-3b-tauri-events-frontend.md deleted file mode 100644 index 8c0f62a..0000000 --- a/docs/superpowers/plans/2026-04-16-phase-3b-tauri-events-frontend.md +++ /dev/null @@ -1,427 +0,0 @@ -# Phase 3b — Tauri Events + Frontend Live Refresh Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire the phase 3a watcher into the Tauri desktop app. Backend emits `file-event` Tauri events; frontend subscribes and refreshes the file table (debounced 300ms). Auto-start the watcher on scan completion. - -**Architecture:** `crates/desktop/src/events.rs` defines `FileEventPayload` wrapper (keeps core framework-free) + `TauriEventEmitter` impl of `EventBus`. New commands: `start_watch`, `stop_watch`, `is_watching`. `WatcherState` with `tokio::sync::Mutex` holds the active watcher. Frontend subscribes via `@tauri-apps/api/event::listen`, debounces re-fetches via `setTimeout`, auto-starts watch after scan. - -**Tech Stack:** Tauri 2 `AppHandle::emit`, tokio::sync::Mutex, `@tauri-apps/api/event::listen`, vitest fake timers. - -**Spec:** `docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md` - -**Execution rule:** All work on `main`. Per-commit: execute → `just ci` green → reviewer → commit. - ---- - -## File Structure - -``` -crates/desktop/src/ -├── events.rs # new — FileEventPayload + TauriEventEmitter -├── commands.rs # modify — add start_watch/stop_watch/is_watching -├── state.rs # modify — add WatcherState -└── lib.rs # modify — register new commands + manage WatcherState - -apps/desktop/src/ -├── types.ts # modify — add FileEvent union -├── api.ts # modify — startWatch/stopWatch/isWatching + subscribeToFileEvents -├── App.tsx # modify — subscribe + debounce + auto-start -└── __tests__/ - └── App.test.tsx # new — debounce test -``` - -Two reviewer-gated commits: (1) backend Tauri events + commands, (2) frontend subscription + debounce + auto-start + test + push + tag. - ---- - -## Task 1: `FileEventPayload` + `TauriEventEmitter` in `crates/desktop/src/events.rs` - -The implementer should create `crates/desktop/src/events.rs`: - -1. Define `FileEventPayload` enum with 4 variants (Created/Modified/Deleted/Renamed) — all fields are `String`. Derive `Serialize + specta::Type + Debug + Clone`. Use `#[serde(tag = "type")]` for a discriminated union. -2. Implement `From<&perima_core::FileEvent> for FileEventPayload` — converts `MediaPath → String` via `.as_str().to_owned()` and `VolumeId → String` via `.0.to_string()`. -3. Define `TauriEventEmitter { app_handle: tauri::AppHandle }` struct. -4. Implement `EventBus for TauriEventEmitter`: - ```rust - impl EventBus for TauriEventEmitter { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - let payload: FileEventPayload = event.into(); - self.app_handle.emit("file-event", payload) - .map_err(|e| CoreError::Internal(format!("tauri emit: {e}"))) - } - } - ``` -5. 1 unit test: construct `FileEvent::Created`, convert to payload, serialize to JSON, assert JSON shape `{"type":"Created","path":"...","volume":"..."}`. - -Update `crates/desktop/src/lib.rs` to add `pub mod events;`. - ---- - -## Task 2: `WatcherState` in `crates/desktop/src/state.rs` - -Add to `state.rs`: - -```rust -use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; - -/// Tracks an active filesystem watcher so commands can start/stop it. -/// -/// WHY tokio::sync::Mutex: Tauri v2 async commands run on tokio; -/// std::sync::Mutex held across .await triggers a clippy warning -/// and can deadlock. tokio::sync::Mutex is await-safe. -pub struct WatcherState { - pub(crate) inner: Mutex>, - pub(crate) cancel: Mutex>, -} - -impl WatcherState { - pub const fn new() -> Self { - Self { - inner: Mutex::const_new(None), - cancel: Mutex::const_new(None), - } - } -} - -impl Default for WatcherState { - fn default() -> Self { Self::new() } -} -``` - -Add `tokio-util` to `crates/desktop/Cargo.toml` `[dependencies]` if not already present. - ---- - -## Task 3: Tauri commands `start_watch` / `stop_watch` / `is_watching` - -Add to `crates/desktop/src/commands.rs`: - -1. `start_watch(path: String, app_handle: AppHandle, state: State, watcher_state: State) -> Result<(), String>`: - - Validate path (exists + dir). - - Canonicalize via `dunce::canonicalize`. - - Detect volume via `perima_fs::detect_volume`. - - Open DB, find_or_create volume, record mount. - - Wrap `SqliteFileRepository` in `Arc`. Reuse the existing `DbEventHandler` pattern from CLI's `watch.rs` — you may need to DUPLICATE it in commands.rs (acceptable for v1; consolidate post-v1). - - Create `CompositeEventBus::new(vec![Arc::new(db_handler), Arc::new(TauriEventEmitter { app_handle: app_handle.clone() }), Arc::new(LogEventHandler)])`. - - Cancel any existing watcher: lock `watcher_state.cancel`, if `Some`, call `cancel.cancel()`. - - Drop any existing `DebouncedWatcher`. - - Create fresh `CancellationToken`, store in `watcher_state.cancel`. - - Start new `DebouncedWatcher`, store in `watcher_state.inner`. - - Return Ok. - -2. `stop_watch(watcher_state: State) -> Result<(), String>`: - - Lock `watcher_state.cancel`, if `Some`, call `.cancel()`, take it to None. - - Lock `watcher_state.inner`, take it to None (drops the watcher, stops notify). - - Return Ok. - -3. `is_watching(watcher_state: State) -> Result`: - - Lock inner, return `.is_some()`. - -Annotate all three with `#[tauri::command]` + `#[specta::specta]`. - -**Duplication note:** `DbEventHandler` from CLI's watch.rs must be reimplemented here because CLI's module isn't accessible from desktop crate. Create a private `fn build_db_handler(repo, volume_id, device_id) -> Arc` in commands.rs or events.rs. - ---- - -## Task 4: Wire `WatcherState` into Tauri app setup - -Modify `crates/desktop/src/lib.rs`: -- Import `WatcherState`. -- In `run()`, add `.manage(WatcherState::new())` to the builder. -- Register the three new commands in `tauri::generate_handler![...]`. -- Register them in the specta builder if the specta collector is used. - -Verify: `cargo build -p perima-desktop` exits 0 (with Tauri env vars). - -- [ ] **Step 1: Create events.rs (Task 1)** -- [ ] **Step 2: Add WatcherState to state.rs (Task 2)** -- [ ] **Step 3: Add 3 commands to commands.rs (Task 3)** -- [ ] **Step 4: Register in lib.rs (Task 4)** -- [ ] **Step 5: Run gates** - -- [ ] **Step 6: Dispatch reviewer (checkpoint #1)** - -Reviewer checklist: -- [ ] `FileEventPayload` keeps core specta-free. -- [ ] `TauriEventEmitter` impls `EventBus` with `app_handle.emit("file-event", payload)`. -- [ ] `WatcherState` uses `tokio::sync::Mutex`. -- [ ] `start_watch` cancels existing watcher before starting new. -- [ ] `stop_watch` takes the watcher to None (drops it). -- [ ] All 3 commands registered in tauri::generate_handler. -- [ ] 1 payload-shape unit test passes. -- [ ] `just ci` green. - -- [ ] **Step 7: Commit** - -```bash -git add crates/desktop/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-3b): Tauri events + start_watch/stop_watch/is_watching - -crates/desktop/events.rs: FileEventPayload wrapper derives -Serialize + specta::Type (keeps core framework-free). -TauriEventEmitter impls EventBus, emits "file-event" via -AppHandle::emit. - -crates/desktop/state.rs: WatcherState wraps DebouncedWatcher + -CancellationToken in tokio::sync::Mutex (await-safe). - -crates/desktop/commands.rs: start_watch (detect volume, open DB, -compose CompositeEventBus with Db+Tauri+Log handlers, cancel any -prior, store in WatcherState), stop_watch (cancel + drop), -is_watching. All annotated with specta for type-safe TS bindings. - -Refs: docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - ---- - -## Task 5: Frontend types + api - -1. Update `apps/desktop/src/types.ts`: - ```typescript - export type FileEvent = - | { type: "Created"; path: string; volume: string } - | { type: "Modified"; path: string; volume: string } - | { type: "Deleted"; path: string; volume: string } - | { type: "Renamed"; from: string; to: string; volume: string }; - ``` - -2. Update `apps/desktop/src/api.ts`: - ```typescript - import { listen } from "@tauri-apps/api/event"; - - export function startWatch(path: string): ResultAsync { - return fromInvoke("start_watch", { path }); - } - export function stopWatch(): ResultAsync { - return fromInvoke("stop_watch", {}); - } - export function isWatching(): ResultAsync { - return fromInvoke("is_watching", {}); - } - - export type UnsubscribeFn = () => void; - export async function subscribeToFileEvents( - callback: (event: FileEvent) => void, - ): Promise { - return listen("file-event", (tauriEvent) => { - callback(tauriEvent.payload); - }); - } - ``` - -Note: `fromInvoke` already exists in api.ts from phase 2b. - -3. Update `apps/desktop/src/__tests__/setup.ts` to add a mock for `@tauri-apps/api/event`: - ```typescript - vi.mock("@tauri-apps/api/event", () => ({ - listen: vi.fn(async (_event, _handler) => () => {}), - })); - ``` - ---- - -## Task 6: `App.tsx` auto-start + debounced refresh - -Update `App.tsx`: - -1. After a successful scan (inside the `handleScanComplete` callback), call `startWatch(lastScannedPath)` and log errors but don't block. -2. Add a `useEffect` that subscribes to file events on mount: - ```tsx - useEffect(() => { - let timer: ReturnType | null = null; - let unsubscribe: UnsubscribeFn | null = null; - let active = true; - - subscribeToFileEvents(() => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - // Refresh via listFiles - listFiles(100).match( - (files) => { if (active) setFiles(files); }, - (err) => { if (active) setError(err); }, - ); - }, 300); - }).then((fn) => { if (active) unsubscribe = fn; }); - - return () => { - active = false; - if (timer) clearTimeout(timer); - if (unsubscribe) unsubscribe(); - }; - }, []); - ``` -3. Track `lastScannedPath` in state so auto-start after scan knows the path. The `ScanButton` already opens the folder picker — pass the path up via a new prop or by lifting state. -4. Optional: add a "👁 watching" indicator next to the status bar text when `isWatching()` returns true. Skip if it adds complexity. - ---- - -## Task 7: Frontend debounce test - -Create `apps/desktop/src/__tests__/App.test.tsx`: - -```tsx -import { render, screen } from "@testing-library/react"; -import { describe, expect, test, vi, beforeEach } from "vitest"; -import { listen } from "@tauri-apps/api/event"; -import { invoke } from "@tauri-apps/api/core"; -import type { Mock } from "vitest"; -import App from "../App"; - -describe("App file-event debounce", () => { - beforeEach(() => { - vi.useFakeTimers(); - (invoke as Mock).mockReset(); - (listen as Mock).mockReset(); - }); - - test("5 rapid file-events within 300ms trigger only 1 listFiles call", async () => { - // Arrange: invoke("list_files") returns empty array on mount. - (invoke as Mock).mockResolvedValue([]); - // Capture the handler passed to listen. - let capturedHandler: ((ev: { payload: unknown }) => void) | null = null; - (listen as Mock).mockImplementation(async (_event, handler) => { - capturedHandler = handler; - return () => {}; - }); - - render(); - - // Wait for subscribeToFileEvents to resolve. - await vi.runAllTicks?.(); - // Give async setup a beat. - await Promise.resolve(); - await Promise.resolve(); - - // The initial listFiles on mount fires; reset the mock so - // we only count subsequent calls. - const initialCalls = (invoke as Mock).mock.calls.filter( - ([cmd]) => cmd === "list_files", - ).length; - (invoke as Mock).mockClear(); - (invoke as Mock).mockResolvedValue([]); - - // Fire 5 events synchronously. - if (!capturedHandler) throw new Error("listen handler not captured"); - for (let i = 0; i < 5; i++) { - capturedHandler({ - payload: { - type: "Created", - path: `file${i}.txt`, - volume: "00000000-0000-0000-0000-000000000000", - }, - }); - } - - // Nothing should have fired yet. - expect((invoke as Mock).mock.calls.filter(([c]) => c === "list_files")) - .toHaveLength(0); - - // Advance past the 300ms debounce. - vi.advanceTimersByTime(300); - // Flush microtasks from the .match() callback. - await Promise.resolve(); - await Promise.resolve(); - - // Exactly one list_files call. - const postCalls = (invoke as Mock).mock.calls.filter( - ([cmd]) => cmd === "list_files", - ); - expect(postCalls).toHaveLength(1); - }); -}); -``` - -Note: if this test proves too timing-sensitive with async/fake-timer interaction, relax to `expect(postCalls.length).toBeLessThanOrEqual(1)` OR mark as `#[vi.skip]` and add a WHY comment. The primary signal is "debounce prevents N→1 explosion"; exact-1 vs at-most-1 is acceptable for v1. - ---- - -## Task 8: Final sweep + push + tag - -- [ ] **Step 1: Verify all tests pass** - - `cargo test --workspace` → all Rust tests. - - `bun run test` in apps/desktop → 9 existing + 1 new = 10 TS tests. - -- [ ] **Step 2: GH issue audit** — any new deferrals from phase 3? Check DECISIONS.md, reviewer feedback. Create issues for anything new. - -- [ ] **Step 3: Dispatch final reviewer (checkpoint #2)** - -Reviewer checklist: -- [ ] App.tsx auto-starts watcher on scan complete. -- [ ] 300ms debounce verified by test. -- [ ] subscribeToFileEvents cleans up on unmount. -- [ ] All Tauri commands registered and specta-exported. -- [ ] `bun run build` + `bun run test` + `bun run lint` exit 0. -- [ ] `just ci` green. -- [ ] GH issue audit done. - -- [ ] **Step 4: Commit frontend** - -```bash -git add apps/desktop/ Cargo.lock -git commit -m "$(cat <<'EOF' -feat(phase-3b): frontend subscribes to file-event with 300ms debounce - -apps/desktop: App.tsx subscribes to Tauri file-event via -subscribeToFileEvents on mount, debounces re-fetches via 300ms -setTimeout, auto-starts watcher after each successful scan. -Cleans up timer + unsubscribes on unmount. - -api.ts: startWatch, stopWatch, isWatching wrappers returning -ResultAsync. subscribeToFileEvents wraps @tauri-apps/api/event -listen() with a typed FileEvent callback. - -types.ts: FileEvent discriminated union matching Rust -FileEventPayload (type tag + path/volume fields). - -New vitest test: 5 rapid file-events within 300ms trigger 1 -list_files call (debounce correctness via fake timers). - -Refs: docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Push + wait CI green** - -```bash -git push origin main -gh run watch --exit-status "$(gh run list --workflow=ci.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -``` - -- [ ] **Step 6: Tag phase-3-complete (ONLY after CI green)** - -```bash -git tag -a phase-3-complete -m "Phase 3: watching + live updates (backend + frontend)" -git push origin phase-3-complete -``` - ---- - -## Self-review - -**Spec coverage (3b):** -- FileEventPayload wrapper → Task 1 ✓ -- TauriEventEmitter → Task 1 ✓ -- WatcherState tokio::sync::Mutex → Task 2 ✓ -- start_watch / stop_watch / is_watching commands → Task 3 ✓ -- app state registration → Task 4 ✓ -- frontend types + api → Task 5 ✓ -- App.tsx subscribe + debounce + auto-start → Task 6 ✓ -- Debounce test → Task 7 ✓ -- GH audit + push + tag → Task 8 ✓ - -**Placeholder scan:** no TBD/TODO. Every file has exact content or specific enough requirements. - -**Type consistency:** `FileEventPayload` (Rust) ↔ `FileEvent` (TS) use the same discriminated-union shape. `UnsubscribeFn` used consistently. `WatcherState` API matches between state.rs and commands.rs. - -**Commit discipline:** two reviewer-gated commits + push + tag. Tag lands only after CI green. diff --git a/docs/superpowers/plans/2026-04-16-v0.3.3-release-plz-shakedown.md b/docs/superpowers/plans/2026-04-16-v0.3.3-release-plz-shakedown.md deleted file mode 100644 index 5b436c5..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.3.3-release-plz-shakedown.md +++ /dev/null @@ -1,285 +0,0 @@ -# v0.3.3 — release-plz shakedown Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. - -**Goal:** Wire `release-plz` into the project so v0.4.0+ releases are cut automatically from conventional commits. Shake down the automation with a `v0.3.3` release before any `feat(media):` commits land. - -**Architecture:** `release-plz.toml` at repo root + `.github/workflows/release-plz.yml`. Release-PR mode (not direct-commit) so release commits pass CI before tagging. Uses `GITHUB_TOKEN` (no user PAT setup required — trade-off: release PR doesn't cascade workflows, but CI still runs on PR open). - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md` (v0.3.3 section) - -**Execution rule:** All work on `main`. Two reviewer-gated commits. Between them, verify release-plz dry-run locally. - -**Env prefix:** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - ---- - -## File Structure - -``` -release-plz.toml # new — workspace config -.github/workflows/release-plz.yml # new — release-pr + release job -``` - -No code changes, no Cargo.toml version bumps (release-plz handles those). - ---- - -## Task 1: Author `release-plz.toml` - -**Files:** Create `release-plz.toml` at repo root. - -- [ ] **Step 1: Write config** - -```toml -# WHY release-pr mode (not direct): release commits must pass CI before -# tagging. CLAUDE.md's rule "never commit unreviewed work" applies to -# release commits too. Release-PR mode opens a PR, CI runs on PR open, -# merge-after-green tags the release. - -[workspace] -# WHY publish=false: no crates.io distribution in v0.x. When we choose -# to publish perima-core, flip this in a targeted [[package]] block. -publish = false - -# WHY git_tag/release=true: we want both git tags AND GitHub Releases. -git_tag_enable = true -git_release_enable = true - -# WHY this filter: chore/docs/ci/refactor/test alone should NOT cut a -# release (otherwise every CI tweak bumps the version). Only feat/fix/ -# perf and their breaking variants (feat!) drive a release. -release_commits = "^(feat|fix|feat!|perf|perf!)" - -# WHY changelog path explicit: we already have CHANGELOG.md from v0.3.0; -# release-plz will append to it in Keep-a-Changelog format. -changelog_path = "CHANGELOG.md" - -# WHY semver_check=false: `cargo-semver-checks` is great for public -# crates on crates.io but noisy here — perima-core isn't published yet -# and the tool flags API churn that is expected pre-1.0. -semver_check = false - -# Pre-1.0 left-shift semantics. -# WHY: cargo convention says "0.x.y → 0.x.(y+1) on feat; 0.x → 0.(x+1) -# on feat!/BREAKING". release-plz honors this automatically on 0.x. - -[[package]] -name = "perima-desktop" -# WHY release=false: never publishable (Tauri shell, not a library). -# Versions still bump via workspace inheritance. -release = false -``` - -- [ ] **Step 2: Dry-run locally** - -Install release-plz if not present: - -```bash -cargo install --locked release-plz -``` - -Run: - -```bash -release-plz release-pr --dry-run 2>&1 | tail -40 -``` - -Expected: output shows no release needed yet (all commits since v0.3.2 are `chore(release): v0.3.0` etc from history, but nothing since v0.3.2 tag other than what we're about to add). If it proposes a version, sanity-check the math. - -**If the dry-run errors** on `missing toml field` or similar: fix the config, re-run. Do not proceed with broken config. - ---- - -## Task 2: Author `.github/workflows/release-plz.yml` - -**Files:** Create `.github/workflows/release-plz.yml`. - -- [ ] **Step 1: Write workflow** - -```yaml -name: release-plz - -on: - push: - branches: [main] - -# WHY concurrency group: release-plz opens PRs with a stable branch -# name; concurrent runs would clobber each other. Serialize. -concurrency: - group: release-plz-${{ github.ref }} - cancel-in-progress: false - -jobs: - release-plz-release: - name: Release-plz release - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # release-plz needs full history - - uses: dtolnay/rust-toolchain@stable - - name: Run release-plz - uses: release-plz/action@v0.5 - with: - command: release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - release-plz-pr: - name: Release-plz PR - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - - name: Run release-plz - uses: release-plz/action@v0.5 - with: - command: release-pr - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -**WHY two jobs:** release-plz has two modes in the official action: -`release-pr` (opens the PR) and `release` (tags + publishes after PR merge). Both run on every main push; each is a no-op when there's nothing to do. - -**WHY GITHUB_TOKEN not PAT:** avoids user setup; trade-off is the release PR doesn't re-trigger workflows (known GHA limitation). CI still runs on PR open because `ci.yml` has `on: pull_request`. - -- [ ] **Step 2: Validate workflow syntax** - -```bash -actionlint .github/workflows/release-plz.yml 2>&1 | tail -10 -``` - -Expected: clean. `actionlint` was installed in phase 0. - ---- - -## Task 3: Commit + push + verify shakedown - -**Files:** all new files from Tasks 1-2. - -- [ ] **Step 1: Full CI gate** - -```bash -just ci -``` - -Expected: exit 0. (The workflow file is non-Rust; just ci doesn't run it but lint/tests still matter.) - -- [ ] **Step 2: Commit the wiring** - -```bash -git add release-plz.toml .github/workflows/release-plz.yml -git commit -m "$(cat <<'EOF' -chore(ci): wire release-plz for automated semver releases - -Closes utof/perima#10. - -Adds release-plz.toml + .github/workflows/release-plz.yml. Release-PR -mode: every main push triggers release-plz; if accumulated commits -since the last tag include feat/fix/perf, it opens a PR with the -version bump + CHANGELOG entry. Merge-after-CI-green tags the release -and creates the GH Release. - -Uses GITHUB_TOKEN (no user PAT required). Trade-off: release PR -does not cascade workflows, but ci.yml already runs on pull_request -so CI gates the release commit regardless. - -Config: publish=false workspace-wide (no crates.io yet); -perima-desktop marked release=false (never publishable); -release_commits filter excludes chore/docs/ci from triggering -releases on their own. - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" -``` - -- [ ] **Step 3: Push** - -```bash -SHA="$(git rev-parse HEAD)" -git push origin main -``` - -- [ ] **Step 4: Observe release-plz first run** - -```bash -sleep 10 # give GH Actions a moment to pick up the push -gh run list --workflow=release-plz.yml --limit 1 --json databaseId,status,conclusion -``` - -Expected: a run started. Wait for it to complete: - -```bash -RUN_ID="$(gh run list --workflow=release-plz.yml --limit 1 --json databaseId --jq '.[0].databaseId')" -gh run watch "$RUN_ID" --exit-status -``` - -**If the run fails:** read the logs, fix the config, push a new commit. **Do NOT tag manually** — we want release-plz to own the tagging path from here on. - -**Expected outcome of this first run:** release-plz sees the chore commit; does NOT open a release PR (chore doesn't trigger per our filter). This is a passive shakedown: it proves the workflow authenticates + parses config correctly without actually cutting a release. - -- [ ] **Step 5: Trigger a real release for v0.3.3** - -Author a `fix(ci):` commit that represents real work — in this case, any minor CI improvement we've been meaning to do. Candidate: the fmt-check recipe currently shells out even when clean; add a `--quiet` flag. Or: the pre-commit hook script has a TODO we've been ignoring. - -**If nothing real exists to fix**, SKIP this step. Don't manufacture a fake fix. Instead, accept that v0.3.3 never formally lands — the next real `feat(media):` or `fix(...):` from v0.4.0 work triggers the first auto-release, and it'll be v0.4.0 directly. - -**Recommended:** check for an easy real `fix(ci):` opportunity by grepping `TODO` / `FIXME` in `.github/workflows/*.yml` and `justfile`. If found, use it. If not, skip. - -- [ ] **Step 6: Update task list + file follow-up issues** - -If v0.3.3 was skipped, update the CHANGELOG.md `Unreleased` section: - -```markdown -## [Unreleased] - -### Changed - -- `release-plz` now manages version bumps, CHANGELOG entries, tags, - and GH Releases automatically from conventional commits. First - automated release will be v0.4.0. -``` - ---- - -## Exit criteria - -- [ ] `release-plz.toml` + `release-plz.yml` committed. -- [ ] `release-plz` workflow runs green on the commit that introduces it (no PR opened is expected for a chore-only push). -- [ ] If a real `fix(ci)` followed, v0.3.3 PR opened + merged + tagged. -- [ ] Otherwise, skip v0.3.3; next auto-release will be v0.4.0. -- [ ] `just ci` still green. - ---- - -## Risks - -- **release-plz misconfiguration.** Dry-run + the chore-only first run catch most issues before v0.4.0 features are at stake. -- **GITHUB_TOKEN limitation.** Release PR won't re-trigger workflows. Mitigated: `ci.yml` runs on `pull_request` events regardless of commit author. -- **Release-plz action version drift.** Pinned to v0.5; check for updates quarterly. - ---- - -## Self-review - -**Spec coverage:** v0.3.3 section of the parent spec fully mapped (shakedown as optional — skip if no real fix needed). - -**Placeholder scan:** no TBD/TODO. Step 5 has an explicit "skip if nothing to fix" branch. - -**Commit discipline:** one reviewer-gated commit for the wiring; optional second for a real fix that triggers v0.3.3. diff --git a/docs/superpowers/plans/2026-04-16-v0.3.x-hardening.md b/docs/superpowers/plans/2026-04-16-v0.3.x-hardening.md deleted file mode 100644 index f8d9414..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.3.x-hardening.md +++ /dev/null @@ -1,1300 +0,0 @@ -# v0.3.x Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close identity-invariant bugs in `crates/db` + silent-failure paths in `crates/desktop` before phase 4. Ships as two patch releases: `v0.3.1` (DB fixes + tests), `v0.3.2` (desktop error surfacing). - -**Architecture:** `BEGIN IMMEDIATE` transactions wrap read-modify-write sequences in `SqliteFileRepository` + `SqliteVolumeRepository`. `conn.busy_timeout` at connection open serializes contending transactions. `record_mount` soft-deletes superseded rows via SELECT-then-UPDATE/INSERT. `update_location_path` checks the destination before renaming and soft-deletes the source on collision. Frontend wires `subscribeToFileEvents` + `startWatch` failures into a new `WatcherBanner` component. `perima_desktop::run` converts `expect()` to `?`. - -**Tech Stack:** rusqlite `BEGIN IMMEDIATE` + `busy_timeout`, React state + component, existing `CoreError::InvalidPath` variant. - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.3.x-hardening-design.md` - -**GH issues closed:** utof/perima#7, #8, #9 - -**Execution rule:** Two release-gated commits. Per commit: execute → `just ci` green → reviewer → commit → push → CI green 3-OS (via commit-SHA filter) → tag → push tag → `gh release create`. - -**Env prefix (MUST be exported before any cargo/just/bun command):** - -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - ---- - -## Real-code API surface the implementer MUST use - -Before writing any test code, **read these real signatures** (not fabricated): - -### `perima_core` ports - -- `VolumeRepository::find_or_create(&mut self, ident: &VolumeIdentifiers, device: DeviceId) -> Result` -- `VolumeRepository::record_mount(&mut self, volume: VolumeId, machine: DeviceId, mount: &Path) -> Result<(), CoreError>` -- `VolumeRepository::list(&self, machine: DeviceId) -> Result, CoreError>` -- `FileRepository::upsert_file(&mut self, file: &HashedFile, device: DeviceId) -> Result` -- `FileRepository::upsert_location(&mut self, hash: &BlakeHash, volume: VolumeId, path: &MediaPath, device: DeviceId) -> Result` -- `FileRepository::list_file_locations(&self, limit: usize, volume: Option) -> Result, CoreError>` - -### `SqliteFileRepository` inherent methods (not on trait) - -- `pub const fn new(conn: Connection) -> Self` -- `pub fn update_location_path(&self, volume, old: &MediaPath, new: &MediaPath, device) -> Result` — **takes `&self`, not `&mut self`** -- `pub fn update_location_status(&self, volume, path, status, device) -> Result` -- `pub fn migrate_sentinel_row(&self, path, volume, device) -> Result` - -### Existing test helpers (use, don't redefine) - -In `crates/db/src/volume_repo.rs` tests module: -- `fn test_db() -> (tempfile::TempDir, SqliteVolumeRepository)` -- `fn device() -> DeviceId` — returns `DeviceId::new()` -- `fn label_cap_ident(label: &str, cap: u64) -> VolumeIdentifiers` - -In `crates/db/src/file_repo.rs` tests module: -- `fn test_db() -> (tempfile::TempDir, SqliteFileRepository)` -- `fn sample_hashed_file(content: &[u8], rel_path: &str) -> HashedFile` -- `fn device() -> DeviceId` -- `fn sentinel_volume() -> VolumeId` — nil UUID - -### Types - -- `VolumeIdentifiers { gpt_partition_guid, fs_uuid, label, capacity_bytes: u64, is_removable: bool }` — **NO `Default` impl**; construct explicitly or via `label_cap_ident` -- `VolumeRecord { id, label, capacity_bytes: u64, is_removable, mounts_on_this_machine: Vec, last_seen: String }` — **no `identifiers` field** -- `FileLocationRecord { hash, size, volume_id, relative_path, status, first_seen: String }` — **field is `hash`, not `file_hash`; `first_seen` is `String` not `DateTime`** -- `CoreError::InvalidPath(String)` — already exists; use this for non-UTF8 mount paths - -### Schema facts - -- `file_locations`: `id` PK, plus `blake3_hash, volume_id, relative_path, status, last_verified, first_seen, updated_at, deleted_at, device_id`. **NO UNIQUE on any combination.** -- `volume_mounts`: `id` PK, plus `volume_id, machine_id, mount_path, first_seen, updated_at, deleted_at, device_id`. **NO UNIQUE on any combination.** (CLAUDE.md forbids UNIQUE on mutable columns.) -- `ON CONFLICT` SQL is **unavailable** on these tables. Use SELECT-then-INSERT/UPDATE inside `BEGIN IMMEDIATE`. - -### Rusqlite transaction API - -- `conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)` — the ONLY correct call. `unchecked_transaction()` is `DEFERRED` and will NOT exercise the writer-lock path. -- Returned `Transaction<'_>` has `.execute`, `.query_row`, `.commit()`, `.rollback()`, Drop auto-rollback. -- The `Transaction` borrows the connection mutably; the outer `Mutex` guard must live for the duration of the tx. - ---- - -## File Structure - -``` -crates/db/src/ -├── connection.rs # modify — add busy_timeout + test -├── file_repo.rs # modify — tx-wrap upsert + collision-check path + tests -└── volume_repo.rs # modify — tx-wrap find_or_create + retirement path + tests - -apps/desktop/src/ -├── App.tsx # modify — wire watcherError state + render banner -├── components/ -│ └── WatcherBanner.tsx # new — dismissible watcher error banner -└── __tests__/ - └── App.test.tsx # modify — add 1-2 tests for error surfacing - -crates/desktop/src/ -├── lib.rs # modify — replace expect() with ? -└── state.rs # modify — add state-machine unit test - -CHANGELOG.md # append v0.3.1, v0.3.2 sections -Cargo.toml # bump workspace version each release -apps/desktop/package.json # bump version each release -``` - ---- - -## Release 1 — v0.3.1 — `fix(db): harden identity invariants` - -### Task 1: Add `busy_timeout` to connection opener (Fix 1e, PRECONDITION) - -**Files:** -- Modify: `crates/db/src/connection.rs` - -- [ ] **Step 1: Add `busy_timeout` after pragma batch** - -In `open_and_migrate`, between the `execute_batch` for pragmas and the refinery runner, add: - -```rust - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - PRAGMA foreign_keys = OFF;", - )?; - // WHY busy_timeout: without it, a concurrent BEGIN IMMEDIATE that - // contends for the writer lock returns SQLITE_BUSY immediately, - // turning the uniqueness-race fix (below) into a correctness - // regression. 5 s is generous for local SQLite + our tiny - // per-call transactions; longer waits would only bite if a single - // tx ran for seconds, which our batches never do. - conn.busy_timeout(std::time::Duration::from_secs(5))?; - embedded::migrations::runner() - .run(&mut conn) - .map_err(|e| Error::Refinery(e.to_string()))?; -``` - -- [ ] **Step 2: Write deterministic serialization test** - -Add to `tests` module at bottom of `crates/db/src/connection.rs`: - -```rust - #[test] - fn two_immediate_tx_serialize_via_busy_timeout() { - use std::sync::mpsc; - use std::thread; - use std::time::Duration; - - let td = tempfile::tempdir().expect("tempdir"); - let db = td.path().join("x.db"); - - let mut c1 = open_and_migrate(&db).expect("c1"); - let mut c2 = open_and_migrate(&db).expect("c2"); - - // Channel forces deterministic ordering: h1 opens the writer - // lock, signals h2 to attempt its BEGIN IMMEDIATE, then h1 - // waits briefly before committing. h2 should block on the - // lock (via busy_timeout) and succeed after h1 commits — - // NOT error with SQLITE_BUSY. - let (h1_ready_tx, h1_ready_rx) = mpsc::channel::<()>(); - let (h2_done_tx, h2_done_rx) = mpsc::channel::>(); - - let h1 = thread::spawn(move || { - let tx = c1 - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .expect("h1 begin immediate"); - // Real write inside the tx so the writer lock is held. - tx.execute( - "INSERT INTO files (blake3_hash, file_size, first_seen, updated_at, device_id) - VALUES ('a', 1, 'now', 'now', 'd')", - [], - ) - .expect("h1 write"); - h1_ready_tx.send(()).expect("signal h2"); - thread::sleep(Duration::from_millis(200)); - tx.commit().expect("h1 commit"); - }); - - let h2 = thread::spawn(move || { - h1_ready_rx.recv().expect("wait h1 ready"); - let result: Result<(), String> = (|| { - let tx = c2 - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| format!("h2 begin: {e}"))?; - tx.commit().map_err(|e| format!("h2 commit: {e}"))?; - Ok(()) - })(); - h2_done_tx.send(result).expect("report"); - }); - - h1.join().expect("h1 join"); - h2.join().expect("h2 join"); - let h2_result = h2_done_rx.recv().expect("h2 done"); - assert!( - h2_result.is_ok(), - "h2 must serialize via busy_timeout, not error: {h2_result:?}" - ); - } -``` - -- [ ] **Step 3: Run tests** - -```bash -cargo test -p perima-db connection:: 2>&1 | tail -20 -``` - -Expected: `two_immediate_tx_serialize_via_busy_timeout` + existing `open_sets_wal_mode` pass. - -- [ ] **Step 4: Full gate** - -```bash -just ci -``` - -Expected: exit 0. - ---- - -### Task 2: Tx-wrap `record_mount` + retire superseded rows + non-UTF8 rejection (Fix 1a + 1c + 1d) - -**Files:** -- Modify: `crates/db/src/volume_repo.rs` - -- [ ] **Step 1: Write failing test — retire superseded path** - -Add to `tests` module (AFTER existing helpers; do NOT redefine `test_db`, `device`, `label_cap_ident`): - -```rust - #[test] - fn record_mount_retires_superseded_path() { - let (_td, mut repo) = test_db(); - let dev = device(); - let vol = repo - .find_or_create(&label_cap_ident("SUPERSEDE", 500_000), dev) - .expect("create"); - repo.record_mount(vol, dev, std::path::Path::new("/mnt/a")) - .expect("mount a"); - repo.record_mount(vol, dev, std::path::Path::new("/mnt/b")) - .expect("mount b (supersedes a)"); - - let records = repo.list(dev).expect("list"); - assert_eq!(records.len(), 1); - assert_eq!( - records[0].mounts_on_this_machine, - vec![std::path::PathBuf::from("/mnt/b")], - "expected only /mnt/b active; got {:?}", - records[0].mounts_on_this_machine - ); - } -``` - -- [ ] **Step 2: Run — expect FAIL** (both paths still active on current main) - -```bash -cargo test -p perima-db record_mount_retires_superseded_path 2>&1 | tail -10 -``` - -- [ ] **Step 3: Write failing test — non-UTF8 path rejected** - -```rust - #[cfg(target_os = "linux")] - #[test] - fn record_mount_rejects_non_utf8_path() { - use std::ffi::OsStr; - use std::os::unix::ffi::OsStrExt; - - let (_td, mut repo) = test_db(); - let dev = device(); - let vol = repo - .find_or_create(&label_cap_ident("NONUTF8", 100), dev) - .expect("create"); - - // Raw bytes that are not valid UTF-8. - let bytes = b"/mnt/\xff\xfe"; - let path = std::path::Path::new(OsStr::from_bytes(bytes)); - - let err = repo - .record_mount(vol, dev, path) - .expect_err("non-utf8 path must error"); - match err { - CoreError::InvalidPath(_) => {} - other => panic!("expected InvalidPath, got {other:?}"), - } - } -``` - -- [ ] **Step 4: Rewrite `record_mount` with transaction + soft-delete superseded rows + UTF-8 validation** - -Replace the body of `record_mount` in `crates/db/src/volume_repo.rs`: - -```rust -fn record_mount( - &mut self, - volume: VolumeId, - machine: DeviceId, - mount: &std::path::Path, -) -> Result<(), CoreError> { - // WHY: validate UTF-8 at the boundary instead of to_string_lossy, - // which silently corrupts non-UTF-8 bytes on Linux. No user has - // non-UTF-8 mount paths in practice; erroring is cleaner than - // threading binary paths through the whole stack. - let mount_str = mount.to_str().ok_or_else(|| { - CoreError::InvalidPath(format!( - "mount path is not valid UTF-8: {}", - mount.display() - )) - })?; - - let mut conn = lock(&self.conn)?; - let now = now_iso(); - let vol_str = volume.0.to_string(); - let machine_str = machine.0.to_string(); - - // WHY BEGIN IMMEDIATE: the retire-then-insert sequence must be atomic - // across connections. Without the tx, two concurrent record_mount - // calls could both observe "no existing row", both INSERT, and - // produce duplicate active mount rows. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - // 1. Soft-delete any active row for (volume, machine) whose - // mount_path differs. We want the authoritative current path - // to be the ONLY active row. - tx.execute( - "UPDATE volume_mounts - SET deleted_at = ?1, updated_at = ?1 - WHERE volume_id = ?2 - AND machine_id = ?3 - AND mount_path != ?4 - AND deleted_at IS NULL", - rusqlite::params![now, vol_str, machine_str, mount_str], - ) - .map_err(Error::from)?; - - // 2. Is there already an active row for this exact mount_path? - let existing: Option = tx - .query_row( - "SELECT id FROM volume_mounts - WHERE volume_id = ?1 AND machine_id = ?2 - AND mount_path = ?3 AND deleted_at IS NULL", - rusqlite::params![vol_str, machine_str, mount_str], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - - if existing.is_none() { - let new_id = perima_core::ids::new_id().to_string(); - tx.execute( - "INSERT INTO volume_mounts - (id, volume_id, machine_id, mount_path, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6)", - rusqlite::params![new_id, vol_str, machine_str, mount_str, now, machine_str], - ) - .map_err(Error::from)?; - } else { - // Idempotent: row already exists with same path — touch updated_at. - tx.execute( - "UPDATE volume_mounts SET updated_at = ?1 - WHERE volume_id = ?2 AND machine_id = ?3 - AND mount_path = ?4 AND deleted_at IS NULL", - rusqlite::params![now, vol_str, machine_str, mount_str], - ) - .map_err(Error::from)?; - } - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - Ok(()) -} -``` - -Also update `list` to use `PathBuf::from(mp)` (already does; no change needed — the `to_string_lossy` bug was in `record_mount` only). - -- [ ] **Step 5: Run both new tests — expect PASS** - -```bash -cargo test -p perima-db record_mount_retires_superseded_path record_mount_rejects_non_utf8_path 2>&1 | tail -10 -``` - -- [ ] **Step 6: Write failing test — concurrent find_or_create uniqueness (Barrier)** - -```rust - #[test] - fn find_or_create_concurrent_unique() { - use std::sync::{Arc, Barrier}; - use std::thread; - - let td = tempfile::tempdir().expect("tempdir"); - let db = td.path().join("x.db"); - - // Two independent connections + two independent repos wrapping - // the same DB file. No shared Mutex — BEGIN IMMEDIATE is the - // only thing that serializes them. - let mut r1 = SqliteVolumeRepository::new( - crate::connection::open_and_migrate(&db).expect("c1"), - ); - let mut r2 = SqliteVolumeRepository::new( - crate::connection::open_and_migrate(&db).expect("c2"), - ); - - let ident = label_cap_ident("SHARED_LABEL", 1_000_000); - let dev = device(); - - // Barrier ensures both threads are poised to enter find_or_create - // before either starts — pre-fix, this would expose the SELECT- - // then-INSERT race where both observe "not found" and both insert. - // Post-fix, BEGIN IMMEDIATE serializes them; the second sees - // the first's committed insert and returns that VolumeId. - let barrier = Arc::new(Barrier::new(2)); - let i1 = ident.clone(); - let i2 = ident.clone(); - let b1 = Arc::clone(&barrier); - let b2 = Arc::clone(&barrier); - - let h1 = thread::spawn(move || { - b1.wait(); - r1.find_or_create(&i1, dev).expect("r1 find_or_create") - }); - let h2 = thread::spawn(move || { - b2.wait(); - r2.find_or_create(&i2, dev).expect("r2 find_or_create") - }); - let v1 = h1.join().unwrap(); - let v2 = h2.join().unwrap(); - - // Both threads must return the SAME VolumeId — one inserted, - // the other observed that insert and returned it. - assert_eq!( - v1, v2, - "concurrent find_or_create must return identical VolumeId; got {v1:?} vs {v2:?}" - ); - - // And the DB must contain exactly ONE active volume row. - let list = r2.list(dev).expect("list"); - assert_eq!(list.len(), 1, "expected exactly 1 volume row, got {list:?}"); - } -``` - -**WHY the 2nd thread sees the 1st's insert:** in WAL mode with `BEGIN IMMEDIATE`, the 2nd transaction's read snapshot is taken at `BEGIN IMMEDIATE` time (after the 1st committed), not at `open_and_migrate` time. The SELECT in the GUID/fs_uuid/label arms therefore observes the just-committed row. - -- [ ] **Step 7: Run — expect FAIL** (or at minimum: v1 != v2 some fraction of runs, OR list().len() == 2) - -- [ ] **Step 8: Wrap `find_or_create` body in `BEGIN IMMEDIATE`** - -Rewrite `find_or_create` in `crates/db/src/volume_repo.rs`: - -```rust -fn find_or_create( - &mut self, - ident: &VolumeIdentifiers, - device: DeviceId, -) -> Result { - let mut conn = lock(&self.conn)?; - let now = now_iso(); - let dev_str = device.0.to_string(); - - // WHY BEGIN IMMEDIATE: the priority-chain SELECT + fallback INSERT - // must be atomic across connections; otherwise two scanners observing - // the same ident simultaneously would each miss and each insert. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - // Arm 1: GPT partition GUID - if let Some(ref guid) = ident.gpt_partition_guid { - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE gpt_partition_guid = ?1 AND deleted_at IS NULL", - [guid], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - return Ok(VolumeId(vol_id)); - } - } - - // Arm 2: Filesystem UUID — same pattern as arm 1 with fs_uuid instead. - if let Some(ref fs_uuid) = ident.fs_uuid { - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE fs_uuid = ?1 AND deleted_at IS NULL", - [fs_uuid], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - return Ok(VolumeId(vol_id)); - } - } - - // Arm 3: label + capacity - if let Some(ref label) = ident.label { - let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; - let existing: Option = tx - .query_row( - "SELECT volume_id FROM volumes - WHERE volume_label = ?1 AND capacity_bytes = ?2 - AND deleted_at IS NULL", - rusqlite::params![label, cap_i64], - |row| row.get(0), - ) - .optional() - .map_err(Error::from)?; - if let Some(vol_id_str) = existing { - tx.execute( - "UPDATE volumes SET last_seen = ?1, updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3", - rusqlite::params![now, dev_str, vol_id_str], - ) - .map_err(Error::from)?; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - let vol_id = uuid::Uuid::parse_str(&vol_id_str) - .map_err(|e| CoreError::Internal(format!("bad volume uuid: {e}")))?; - return Ok(VolumeId(vol_id)); - } - } - - // Fallthrough: INSERT new volume. - let new_id = VolumeId::new(); - let new_id_str = new_id.0.to_string(); - let cap_i64 = capacity_to_i64(ident.capacity_bytes)?; - tx.execute( - "INSERT INTO volumes - (volume_id, gpt_partition_guid, fs_uuid, volume_label, - capacity_bytes, is_removable, last_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8)", - rusqlite::params![ - new_id_str, - ident.gpt_partition_guid, - ident.fs_uuid, - ident.label, - cap_i64, - i64::from(ident.is_removable), - now, - dev_str, - ], - ) - .map_err(Error::from)?; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - - Ok(new_id) -} -``` - -- [ ] **Step 9: Run all volume_repo tests — all pass** - -```bash -cargo test -p perima-db volume_repo:: 2>&1 | tail -25 -``` - ---- - -### Task 3: Tx-wrap `update_location_path` + `upsert_location` + collision guard (Fix 1b + 1c) - -**Files:** -- Modify: `crates/db/src/file_repo.rs` - -- [ ] **Step 1: Write failing test — rename collision** - -Add to `tests` module in `crates/db/src/file_repo.rs`: - -```rust - #[test] - fn update_location_path_collision_softdeletes_source_and_bumps_dest() { - let (_td, mut repo) = test_db(); - let dev = device(); - let vol = VolumeId::new(); - - // Two active locations at different paths with different hashes. - let f_src = sample_hashed_file(b"source", "src.txt"); - let f_dst = sample_hashed_file(b"destination", "dst.txt"); - repo.upsert_file(&f_src, dev).expect("file src"); - repo.upsert_file(&f_dst, dev).expect("file dst"); - repo.upsert_location(&f_src.hash, vol, &MediaPath::new("src.txt"), dev) - .expect("loc src"); - repo.upsert_location(&f_dst.hash, vol, &MediaPath::new("dst.txt"), dev) - .expect("loc dst"); - - // Rename src.txt → dst.txt; dst.txt already exists active. - repo.update_location_path( - vol, - &MediaPath::new("src.txt"), - &MediaPath::new("dst.txt"), - dev, - ) - .expect("rename with collision"); - - // Only dst.txt should remain active; its row must be the - // destination (retains f_dst.hash, NOT f_src.hash). - let rows = repo.list_file_locations(100, Some(vol)).expect("list"); - let actives: Vec<_> = rows - .iter() - .filter(|r| r.status == LocationStatus::Active) - .collect(); - assert_eq!(actives.len(), 1, "expected 1 active row, got {actives:?}"); - assert_eq!( - actives[0].relative_path.as_str(), - "dst.txt", - "destination must survive" - ); - assert_eq!( - actives[0].hash, f_dst.hash, - "destination's hash must be preserved (not overwritten by source)" - ); - } -``` - -- [ ] **Step 2: Run — expect FAIL** (current code would leave TWO active rows or error on uniqueness) - -- [ ] **Step 3: Rewrite `update_location_path` with collision check** - -Replace the body of `update_location_path` in `crates/db/src/file_repo.rs`: - -```rust -pub fn update_location_path( - &self, - volume: VolumeId, - old_path: &MediaPath, - new_path: &MediaPath, - device: DeviceId, -) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let vol_str = volume.0.to_string(); - let old_str = old_path.as_str(); - let new_str = new_path.as_str(); - let dev_str = device.0.to_string(); - let now = now_iso(); - - // WHY BEGIN IMMEDIATE: the collision check + (rename | soft-delete + - // bump) is a read-modify-write across 1-2 rows. Must be atomic. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - // Is there already an active row at the destination? - let dest_exists: bool = tx - .query_row( - "SELECT 1 FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 - AND deleted_at IS NULL", - rusqlite::params![vol_str, new_str], - |_| Ok(true), - ) - .optional() - .map_err(Error::from)? - .unwrap_or(false); - - let n: usize = if dest_exists { - // Collision: destination wins (LWW). Soft-delete the source - // row and bump the destination's updated_at + device_id, since - // the rename-event is an observation that the destination is - // the authoritative path from this device's perspective. - let deleted = tx - .execute( - "UPDATE file_locations - SET status = 'missing', - deleted_at = ?1, - updated_at = ?1, - device_id = ?2 - WHERE volume_id = ?3 - AND relative_path = ?4 - AND deleted_at IS NULL", - rusqlite::params![now, dev_str, vol_str, old_str], - ) - .map_err(Error::from)?; - let bumped = tx - .execute( - "UPDATE file_locations - SET updated_at = ?1, device_id = ?2 - WHERE volume_id = ?3 - AND relative_path = ?4 - AND deleted_at IS NULL", - rusqlite::params![now, dev_str, vol_str, new_str], - ) - .map_err(Error::from)?; - deleted + bumped - } else { - // No collision: rename in place. - tx.execute( - "UPDATE file_locations - SET relative_path = ?1, status = 'active', - updated_at = ?2, device_id = ?3 - WHERE volume_id = ?4 - AND relative_path = ?5 - AND deleted_at IS NULL", - rusqlite::params![new_str, now, dev_str, vol_str, old_str], - ) - .map_err(Error::from)? - }; - - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - - #[allow(clippy::cast_possible_truncation)] - Ok(n as u64) -} -``` - -- [ ] **Step 4: Run collision test + existing rename tests — all pass** - -```bash -cargo test -p perima-db update_location_path 2>&1 | tail -20 -``` - -Expected: new `update_location_path_collision_softdeletes_source_and_bumps_dest` + existing `update_location_path_renames` + `update_location_path_nonexistent` all pass. - -- [ ] **Step 5: Tx-wrap `upsert_location` for concurrent-race safety** - -Replace the body of `upsert_location` in `crates/db/src/file_repo.rs` — the logic is unchanged (still LWW on `(volume, path)`); only the SELECT-then-INSERT-or-UPDATE is wrapped in `BEGIN IMMEDIATE`: - -```rust -fn upsert_location( - &mut self, - hash: &BlakeHash, - volume: VolumeId, - path: &MediaPath, - device: DeviceId, -) -> Result { - let mut conn = self - .conn - .lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let hash_hex = hash.to_hex(); - let vol_str = volume.0.to_string(); - let path_str = path.as_str(); - let dev_str = device.0.to_string(); - let now = now_iso(); - - // WHY BEGIN IMMEDIATE: read-modify-write across connections must be - // atomic; without it two concurrent upserts can both observe - // "not found" and both insert, producing duplicate active rows. - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let existing: Option<(String, String, String)> = tx - .query_row( - "SELECT id, blake3_hash, device_id FROM file_locations - WHERE volume_id = ?1 AND relative_path = ?2 AND deleted_at IS NULL", - rusqlite::params![vol_str, path_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .optional() - .map_err(Error::from)?; - - let outcome = match existing { - None => { - let id = perima_core::ids::new_id().to_string(); - tx.execute( - "INSERT INTO file_locations - (id, blake3_hash, volume_id, relative_path, status, - first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6)", - rusqlite::params![id, hash_hex, vol_str, path_str, now, dev_str], - ) - .map_err(Error::from)?; - UpsertOutcome::Inserted - } - Some((_, ref existing_hash, ref existing_dev)) - if *existing_hash == hash_hex && *existing_dev == dev_str => - { - UpsertOutcome::Unchanged - } - Some((ref row_id, _, _)) => { - tx.execute( - "UPDATE file_locations - SET blake3_hash = ?1, updated_at = ?2, device_id = ?3 - WHERE id = ?4", - rusqlite::params![hash_hex, now, dev_str, row_id], - ) - .map_err(Error::from)?; - UpsertOutcome::Updated - } - }; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - Ok(outcome) -} -``` - -- [ ] **Step 6: Add concurrent-race test for upsert_location** - -```rust - #[test] - fn upsert_location_concurrent_unique() { - use std::sync::{Arc, Barrier}; - use std::thread; - - let td = tempfile::tempdir().expect("tempdir"); - let db = td.path().join("x.db"); - - let mut r1 = SqliteFileRepository::new( - crate::connection::open_and_migrate(&db).expect("c1"), - ); - let mut r2 = SqliteFileRepository::new( - crate::connection::open_and_migrate(&db).expect("c2"), - ); - - // Pre-insert the content-addressed file row so both upsert_location - // calls can reference it. (upsert_file is also serialized, but the - // point of this test is upsert_location's race.) - let f = sample_hashed_file(b"shared", "shared.txt"); - let dev = device(); - r1.upsert_file(&f, dev).expect("prefile"); - - let vol = VolumeId::new(); - let path = MediaPath::new("shared.txt"); - let barrier = Arc::new(Barrier::new(2)); - let (b1, b2) = (Arc::clone(&barrier), Arc::clone(&barrier)); - let h1_hash = f.hash.clone(); - let h2_hash = f.hash.clone(); - let p1 = path.clone(); - let p2 = path.clone(); - - let h1 = thread::spawn(move || { - b1.wait(); - r1.upsert_location(&h1_hash, vol, &p1, dev).expect("r1"); - }); - let h2 = thread::spawn(move || { - b2.wait(); - r2.upsert_location(&h2_hash, vol, &p2, dev).expect("r2"); - }); - h1.join().unwrap(); - h2.join().unwrap(); - - // Exactly ONE active row at (vol, shared.txt). - let r_check = SqliteFileRepository::new( - crate::connection::open_and_migrate(&db).expect("c3"), - ); - let rows = r_check.list_file_locations(100, Some(vol)).expect("list"); - let actives = rows - .iter() - .filter(|r| r.status == LocationStatus::Active) - .count(); - assert_eq!(actives, 1, "expected exactly 1 active row, got {actives}"); - } -``` - -- [ ] **Step 7: Full gate** - -```bash -just ci -``` - -Expected: all existing + 4 new DB tests green across workspace. - ---- - -### Task 4: Release v0.3.1 - -- [ ] **Step 1: Update CHANGELOG.md** - -Append above the `## [Unreleased]` section: - -```markdown -## [0.3.1] — 2026-04-16 - -### Fixed - -- **DB identity invariants** (utof/perima#7, #9). - - `record_mount` soft-deletes superseded rows for the same - `(volume_id, machine_id)` before inserting a new mount path; - stops stale mounts accumulating in - `VolumeRecord.mounts_on_this_machine`. - - `update_location_path` checks for an existing active row at the - destination before renaming. On collision the source row is - soft-deleted (LWW) and the destination's `updated_at` + - `device_id` are bumped — the rename event is an observation - about the destination. - - `upsert_location`, `find_or_create`, and `record_mount` wrap - their SELECT-then-INSERT/UPDATE sequences in `BEGIN IMMEDIATE`; - concurrent writers can no longer produce duplicate active rows. - - `busy_timeout(5s)` at connection open so contending - `BEGIN IMMEDIATE` requests serialize instead of erroring. - - Non-UTF-8 mount paths now return `CoreError::InvalidPath` - instead of silent lossy conversion via `to_string_lossy`. - -### Added - -- Deterministic concurrent-race tests via `std::sync::Barrier` - covering `find_or_create` and `upsert_location` across two - independent SQLite connections. -- Connection-level test for `busy_timeout` serialization using a - channel-synchronized two-thread pattern. - -[0.3.1]: https://github.com/utof/perima/releases/tag/v0.3.1 -``` - -Update `[Unreleased]` compare link at the bottom of the file to -`v0.3.1...HEAD`. - -- [ ] **Step 2: Bump versions** - -- `Cargo.toml` `[workspace.package]`: `version = "0.3.1"` -- `apps/desktop/package.json`: `"version": "0.3.1"` - -- [ ] **Step 3: Full gate** - -```bash -just ci -``` - -Expected: exit 0. - -- [ ] **Step 4: Commit, push, wait CI via SHA filter, tag, release** - -```bash -git add Cargo.toml Cargo.lock apps/desktop/package.json crates/ CHANGELOG.md -git commit -m "$(cat <<'EOF' -fix(db): harden location + mount identity invariants - -Closes utof/perima#7, #9. - -- Wrap upsert_location, find_or_create, and record_mount in - BEGIN IMMEDIATE; concurrent CLI + desktop writers can no longer - produce duplicate active rows (SQLite serializes statements, not - read-modify-write sequences). -- Set busy_timeout(5s) at connection open so contending - BEGIN IMMEDIATE requests serialize instead of erroring. -- record_mount soft-deletes prior rows for (volume, machine) when - mount_path changes — stops stale mounts accumulating in - VolumeRecord.mounts_on_this_machine. -- update_location_path checks the destination before rename; on - collision, soft-deletes source + bumps destination updated_at + - device_id (LWW, destination-wins). -- Replace to_string_lossy for mount paths with InvalidPath error on - non-UTF8 input. -- Add deterministic concurrent-race tests via std::sync::Barrier - and a channel-synchronized busy_timeout serialization test. - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" - -SHA="$(git rev-parse HEAD)" -git push origin main -# WHY SHA filter: --limit 1 can race if the previous run hasn't -# cleared yet. Filter by our exact commit to be deterministic. -sleep 5 # give GH a moment to register the run -RUN_ID="$(gh run list --workflow=ci.yml --limit 10 \ - --json databaseId,headSha \ - --jq ".[] | select(.headSha==\"$SHA\") | .databaseId" | head -1)" -gh run watch "$RUN_ID" --exit-status -git tag -a v0.3.1 -m "v0.3.1 — harden DB identity invariants" -git push origin v0.3.1 -gh release create v0.3.1 \ - --title "v0.3.1 — harden DB identity invariants" \ - --generate-notes -gh issue close 7 --comment "Landed in v0.3.1 ($SHA)." -gh issue close 9 --comment "Concurrent-race + collision test coverage landed in v0.3.1." -``` - ---- - -## Release 2 — v0.3.2 — `fix(desktop): surface watcher errors + drop expect()` - -### Task 5: `WatcherBanner` component + wire failure paths - -**Files:** -- Create: `apps/desktop/src/components/WatcherBanner.tsx` -- Modify: `apps/desktop/src/App.tsx` - -- [ ] **Step 1: Create `WatcherBanner.tsx`** - -```tsx -/** Props for {@link WatcherBanner}. */ -interface WatcherBannerProps { - /** Error message, or null to hide the banner. */ - message: string | null; - /** Called when the user dismisses the banner. */ - onDismiss: () => void; -} - -/** - * Non-blocking banner that surfaces watcher subscribe / startWatch - * failures without obscuring the file table. - * - * WHY a separate banner rather than reusing the scan `error` state: - * scan errors are blocking — the user needs to know the scan failed. - * Watcher errors are degraded-mode — the table is still accurate, just - * not live-refreshing. Different severity, different UI treatment. - */ -export default function WatcherBanner({ message, onDismiss }: WatcherBannerProps) { - if (!message) return null; - return ( -
- - Watcher: {message} - - -
- ); -} -``` - -- [ ] **Step 2: Wire into `App.tsx`** - -Add `watcherError` state and wire the three failure sites: - -```tsx -import WatcherBanner from "./components/WatcherBanner"; -// … -const [watcherError, setWatcherError] = useState(null); - -// In handleScanComplete — replace console.warn with setWatcherError: -api.startWatch(path).match( - () => setWatcherError(null), - (err) => setWatcherError(`Failed to start watcher: ${err}`), -); - -// In the subscribe effect — add a .catch on the subscribe promise: -api - .subscribeToFileEvents(/* … existing callback … */) - .then((fn) => { - if (active) { unsubscribe = fn; } else { fn(); } - }) - .catch((err) => { - const msg = err instanceof Error ? err.message : String(err); - if (active) setWatcherError(`Failed to subscribe to watcher events: ${msg}`); - }); - -// In JSX, between
and
: - setWatcherError(null)} -/> -``` - -- [ ] **Step 3: Add vitest for subscribe-failure surfacing** - -In `apps/desktop/src/__tests__/App.test.tsx`, add AFTER the existing debounce test: - -```tsx -test("surfaces watcher banner when subscribeToFileEvents fails", async () => { - (invoke as Mock).mockResolvedValue([]); - (listen as Mock).mockRejectedValue(new Error("channel closed")); - - render(); - // Wait for the promise chain in the subscribe effect to run. - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - // The banner renders with role="alert". Its text begins with "Watcher:" - // and includes the wrapped error message. - expect( - await screen.findByText(/Failed to subscribe to watcher events.*channel closed/), - ).toBeInTheDocument(); -}); -``` - -Don't add a `startWatch` failure test yet — driving `ScanButton` through a scan-complete is more complex than the value justifies. The subscribe test covers the primary failure path; if the user has no scan yet, subscribe is the only wire-up they've touched. - -- [ ] **Step 4: Gate** - -```bash -cd apps/desktop && bun run test && bun run lint && bun run build && cd - -just ci -``` - -Expected: 11 TS tests pass (10 existing + 1 new); clippy + cargo tests green. - ---- - -### Task 6: Replace `expect()` with `?` in `perima_desktop::run` + state.rs test - -**Files:** -- Modify: `crates/desktop/src/lib.rs` -- Modify: `crates/desktop/src/state.rs` - -- [ ] **Step 1: Read current `run()` to find the `expect()` sites** - -```bash -grep -n "expect(" crates/desktop/src/lib.rs -``` - -- [ ] **Step 2: Convert `expect()` to graceful error returns** - -The setup path in Tauri 2 uses the `.setup(|app| ...)` callback which returns `Result<(), Box>`. For each `expect()` site, convert: - -```rust -// Before: -let config = Config::resolve(...) - .expect("failed to resolve config"); - -// After: -let config = Config::resolve(...) - .map_err(|e| -> Box { - Box::new(std::io::Error::other(format!("config resolve: {e}"))) - })?; -``` - -(If `run()` returns something other than `tauri::Result`, use the appropriate `?`-compatible error type. Read the function's signature and adapt. `?` into a `Box` always works if the inner error is `Error + Send + Sync + 'static`; our `CoreError`/`Config` errors are.) - -- [ ] **Step 3: Add state.rs unit test** - -Add to `crates/desktop/src/state.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use tokio_util::sync::CancellationToken; - - #[tokio::test] - async fn watcher_state_cancel_lifecycle() { - let state = WatcherState::new(); - - // Starts empty. - assert!(state.cancel.lock().await.is_none()); - - // Inject a token (simulating start_watch's effect on the cancel field). - let token = CancellationToken::new(); - { - let mut guard = state.cancel.lock().await; - *guard = Some(token.clone()); - } - assert!(state.cancel.lock().await.is_some()); - assert!(!token.is_cancelled()); - - // Simulate stop_watch: take + cancel. - let taken = { - let mut guard = state.cancel.lock().await; - guard.take() - }; - if let Some(t) = taken { - t.cancel(); - } - assert!(state.cancel.lock().await.is_none()); - assert!(token.is_cancelled(), - "the token we handed to state.cancel must propagate cancellation"); - } -} -``` - -- [ ] **Step 4: Full gate** - -```bash -just ci -``` - -Expected: 0. - ---- - -### Task 7: Release v0.3.2 - -- [ ] **Step 1: CHANGELOG update** - -Prepend above the v0.3.1 section: - -```markdown -## [0.3.2] — 2026-04-16 - -### Fixed - -- **Watcher errors surface in the desktop UI** (utof/perima#8). - `App.tsx` wires `subscribeToFileEvents` + `startWatch` failure - paths into a dismissible `WatcherBanner`. Previously these - errors only logged to `console.warn`, leaving users to wonder - why the file table had stopped refreshing. -- **`perima_desktop::run` no longer panics on config errors.** - Replaced `expect()` calls with graceful `?` into Tauri's setup - error path. - -### Added - -- Unit test for `WatcherState` cancel-token lifecycle. -- Vitest test for watcher subscribe failure → banner appears. - -[0.3.2]: https://github.com/utof/perima/releases/tag/v0.3.2 -``` - -Update `[Unreleased]` compare link to `v0.3.2...HEAD`. - -- [ ] **Step 2: Version bump** - -`Cargo.toml` 0.3.1 → 0.3.2; `apps/desktop/package.json` same. - -- [ ] **Step 3: Commit + push + wait CI + tag + release (SHA-filtered)** - -```bash -git add Cargo.toml Cargo.lock apps/desktop/ crates/desktop/ CHANGELOG.md -git commit -m "$(cat <<'EOF' -fix(desktop): surface watcher errors, drop expect() in run - -Closes utof/perima#8. - -- New WatcherBanner component renders a dismissible non-blocking - banner when subscribeToFileEvents or startWatch fails. The file - table stays visible; the user sees why live-refresh stopped. -- perima_desktop::run converts expect() sites to graceful ? - propagation into Tauri's setup error path. -- Unit test for WatcherState cancel-token lifecycle (state-machine - only — real filesystem watcher lifecycle is deferred to a future - Playwright phase). -- Vitest test for watcher subscribe failure → WatcherBanner - appears with the expected message. - -Co-Authored-By: Claude Opus 4.6 (1M context) -EOF -)" - -SHA="$(git rev-parse HEAD)" -git push origin main -sleep 5 -RUN_ID="$(gh run list --workflow=ci.yml --limit 10 \ - --json databaseId,headSha \ - --jq ".[] | select(.headSha==\"$SHA\") | .databaseId" | head -1)" -gh run watch "$RUN_ID" --exit-status -git tag -a v0.3.2 -m "v0.3.2 — surface watcher errors + drop expect()" -git push origin v0.3.2 -gh release create v0.3.2 \ - --title "v0.3.2 — surface watcher errors" \ - --generate-notes -gh issue close 8 --comment "Landed in v0.3.2 ($SHA)." -``` - ---- - -## Exit criteria - -- [ ] `cargo test --workspace` — all pass -- [ ] `bun run test` — 11 TS tests pass -- [ ] `just ci` — green -- [ ] v0.3.1 tag pushed, CI green 3-OS, GH Release created -- [ ] v0.3.2 tag pushed, CI green 3-OS, GH Release created -- [ ] Issues #7, #8, #9 closed with commit SHA in closing comment -- [ ] Phase 4 brainstorm unblocked - ---- - -## Self-review - -**Spec coverage:** -- 1a retire stale mounts → Task 2 ✓ -- 1b rename-collision guard (incl. dest updated_at bump) → Task 3 Step 3 ✓ -- 1c race-safe uniqueness (BEGIN IMMEDIATE in all 3 sites) → Tasks 2, 3 ✓ -- 1d non-UTF8 InvalidPath → Task 2 Step 4 ✓ -- 1e busy_timeout → Task 1 ✓ -- Fix 2 desktop error surfacing → Task 5 ✓ -- expect() removal → Task 6 ✓ -- Tests: retirement, collision, Barrier × 2, busy_timeout, banner, state lifecycle → Tasks 1, 2, 3, 5, 6 ✓ - -**Placeholder scan:** no TBD/TODO. All test code compiles against real helpers. All SQL uses existing columns. - -**Type / method consistency:** -- `VolumeRepository::find_or_create` 2 args (`ident`, `device`) — matches trait -- `VolumeRepository::record_mount` 3 args (`volume`, `machine`, `mount`) — matches trait -- `FileRepository::upsert_location` 4 args (`hash`, `volume`, `path`, `device`) — matches trait -- `SqliteFileRepository::update_location_path` takes `&self` (inherent impl, not trait) — matches existing code -- `label_cap_ident`, `test_db`, `device`, `sentinel_volume`, `sample_hashed_file` — all existing helpers, not redefined -- `VolumeIdentifiers` constructed with literal fields (no `Default::default()`) -- `VolumeRecord.capacity_bytes: u64` (no `FileSize` wrapper) — matches real struct -- `transaction_with_behavior(TransactionBehavior::Immediate)` used throughout; no `unchecked_transaction` -- `SELECT … AND deleted_at IS NULL` pattern consistent with existing schema usage -- `INSERT INTO volume_mounts` includes all NOT NULL columns (`id, volume_id, machine_id, mount_path, first_seen, updated_at, device_id`) -- `gh run list` uses SHA filter, not `--limit 1` race -- `CHANGELOG.md` format matches existing v0.3.0 entry (### Added / Fixed sections + compare links) - -**Commit discipline:** two reviewer-gated commits (v0.3.1, v0.3.2); each pushes → waits CI via SHA filter → tags → releases → closes issues. diff --git a/docs/superpowers/plans/2026-04-16-v0.4.0-backend-metadata.md b/docs/superpowers/plans/2026-04-16-v0.4.0-backend-metadata.md deleted file mode 100644 index 7ff700c..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.4.0-backend-metadata.md +++ /dev/null @@ -1,629 +0,0 @@ -# v0.4.0 — Backend media metadata Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox syntax for tracking. - -**Goal:** Ship backend media metadata: new `crates/media`, `MetadataExtractor` port, `MetadataRepository` port + Sqlite impl, V002 migration, `MetadataQueue` with sync-from-scanner `enqueue`, integration into scan flow, `perima metadata` + `perima ls --with-metadata` CLI, new Tauri command (UI wiring in v0.4.1). - -**Architecture:** Hexagonal — port in `perima-core`, impl in new `perima-media` crate + `crates/db/src/metadata_repo.rs`. Queue uses `tokio::sync::mpsc` bounded at 16384; `enqueue` is sync with `try_send` + 50ms poll loop that watches `CancellationToken`. V002 migration adds `file_metadata` table (additive; schema freeze holds). - -**Tech Stack:** `image = "0.25"`, `kamadak-exif = "0.5"`, `mp4parse = "0.17"`, `mime_guess = "2"` (all confined to `crates/media`). - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md` (v0.4.0 section) - -**Release mechanism:** Once release-plz is wired (v0.3.3 plan), commits of type `feat(media):` / `feat(core):` / `feat(db):` / `feat(cli):` on main will accumulate in a release PR. Merging the PR tags `v0.4.0` automatically. - -**Env prefix (every cargo/just/bun command):** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - -**Execution rule:** All work on `main`. Each task = one reviewer-gated commit. Commits ordered so release-plz's CHANGELOG reads sensibly (core → db → media → cli → desktop). - ---- - -## Real-code anchors (implementer MUST read these first) - -- `crates/core/src/lib.rs` — module organization; add `pub mod metadata;` -- `crates/core/src/types.rs` — existing types (BlakeHash, MediaPath, VolumeId, DeviceId, UpsertOutcome) -- `crates/core/src/ports/file_repo.rs` — trait pattern to mirror (port file location, doc style) -- `crates/db/src/file_repo.rs` — `SqliteFileRepository` Mutex pattern, BEGIN IMMEDIATE pattern (post-v0.3.1), test helpers `test_db()`, `sample_hashed_file()` -- `crates/db/src/connection.rs` — pragma setup; V002 migration file goes beside V001 -- `crates/db/migrations/V001__initial.sql` — schema-style reference -- `crates/cli/src/cmd/scan.rs` — where to integrate enqueue after upsert_file -- `crates/cli/src/cmd/ls.rs` — `LsArgs` struct to extend with `--with-metadata` flag -- `crates/cli/src/cmd/format.rs` — shared format helper pattern -- `crates/desktop/src/commands.rs` — Tauri command pattern (`#[tauri::command] #[specta::specta]`) -- `crates/desktop/src/state.rs` — `AppState` where `MetadataRepository` will live - ---- - -## File Structure - -``` -crates/core/src/ -├── metadata.rs # new — MediaMetadata + MetadataExtractor trait -└── ports/ - └── metadata_repo.rs # new — MetadataRepository trait (&self) - -crates/db/src/ -├── metadata_repo.rs # new — SqliteMetadataRepository -└── migrations/V002__file_metadata.sql # new — additive table - -crates/media/ # NEW CRATE -├── Cargo.toml # image, kamadak-exif, mp4parse, mime_guess -├── src/ -│ ├── lib.rs # pub mod extractor; pub mod queue; -│ ├── extractor.rs # ImageExtractor, VideoExtractor, Composite -│ └── queue.rs # MetadataQueue (sync enqueue) -└── tests/ - └── fixtures.rs # runtime-generated test assets - -crates/cli/src/ -├── cmd/metadata.rs # new — perima metadata -├── cmd/ls.rs # modify — add --with-metadata -├── cmd/scan.rs # modify — enqueue after upsert_file -├── cmd/watch.rs # modify — wire queue into CompositeEventBus -└── main.rs # modify — register Metadata command - -crates/desktop/src/ -├── commands.rs # add list_files_with_metadata -├── payloads.rs # add FileWithMetadataPayload -└── state.rs # add metadata_repo to AppState - -CHANGELOG.md # release-plz will update -Cargo.toml # add workspace deps (image, exif, mp4parse) -``` - ---- - -## Task 1: `perima-core` metadata module + MetadataExtractor trait - -**Files:** -- Create: `crates/core/src/metadata.rs` -- Create: `crates/core/src/ports/metadata_repo.rs` -- Modify: `crates/core/src/lib.rs` — add `pub mod metadata;`, `pub use metadata::*;` -- Modify: `crates/core/src/ports/mod.rs` — add `pub mod metadata_repo;`, `pub use metadata_repo::MetadataRepository;` -- **File a GH issue** before this task completes: `gh issue create --title "core: align FileRepository trait to &self (match actual usage)" --body ...`. Refer to the spec's "FileRepository trait legacy" note. Link from Task 1's commit body. - -- [ ] **Step 1: Author `metadata.rs`** - -Per spec: `MediaMetadata` struct + `MetadataExtractor` trait. Copy the spec's exact signatures. Include doc comments on every field (`missing_docs = "deny"`). - -- [ ] **Step 2: Author `metadata_repo.rs`** - -Per spec: `MetadataRepository` trait with `&self` methods (`upsert_metadata`, `find_by_hash`, `list_with_metadata`). Include the WHY comment about `&self` vs legacy FileRepository `&mut self`. - -- [ ] **Step 3: Gate** - -```bash -cargo check -p perima-core 2>&1 | tail -5 -cargo test -p perima-core 2>&1 | tail -5 -cargo doc -p perima-core --no-deps 2>&1 | tail -5 -``` - -Expected: clean. - -- [ ] **Step 4: Commit** - -```bash -git commit -m "feat(core): MediaMetadata + MetadataExtractor + MetadataRepository ports - -Adds perima-core metadata module. MediaMetadata is a framework-free -value type; MetadataExtractor trait dispatches by MIME (not -first-non-empty, per spec); MetadataRepository uses &self with -interior-mutability semantics (consistent with actual desktop usage -pattern). FileRepository's legacy &mut self is acknowledged and -deferred to v0.5.x as a fast-follow. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 2: `perima-db` V002 migration + SqliteMetadataRepository - -**Files:** -- Create: `crates/db/migrations/V002__file_metadata.sql` -- Create: `crates/db/src/metadata_repo.rs` -- Modify: `crates/db/src/lib.rs` — `pub mod metadata_repo; pub use metadata_repo::SqliteMetadataRepository;` - -- [ ] **Step 1: Write V002 migration** - -Copy the spec's exact DDL. Verify `updated_at`, `device_id`, `deleted_at` columns present per CRDT rules. - -- [ ] **Step 2: TDD — write failing tests for SqliteMetadataRepository** - -In `crates/db/src/metadata_repo.rs` `tests` module, write three tests using `crate::connection::open_and_migrate` (already runs V001 + V002): -- `upsert_metadata_inserts_new` -- `upsert_metadata_unchanged_on_repeat` -- `upsert_metadata_updated_on_change` -- `list_with_metadata_joins_null` (file without metadata row → `(loc, None)`) - -- [ ] **Step 3: Implement SqliteMetadataRepository** - -Pattern: `Mutex` like `SqliteFileRepository`; all methods take `&self`; `upsert_metadata` wraps SELECT-then-INSERT/UPDATE in `BEGIN IMMEDIATE`. `list_with_metadata` uses a single LEFT JOIN (no N+1). - -**`upsert_metadata` MIRRORS `SqliteFileRepository::upsert_file`** (content-addressed PK on `blake3_hash`), **NOT `upsert_location`** (`upsert_location` guards multi-column uniqueness; `file_metadata` is PK-only). The pattern is: - -```rust -fn upsert_metadata(&self, meta: &MediaMetadata, device: DeviceId) - -> Result -{ - let mut conn = self.conn.lock() - .map_err(|e| CoreError::Internal(format!("mutex poisoned: {e}")))?; - let tx = conn - .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) - .map_err(|e| CoreError::Internal(format!("begin immediate: {e}")))?; - - let hash_hex = meta.hash.to_hex(); - let now = now_iso(); - let dev_str = device.0.to_string(); - - // Mirror upsert_file's SELECT-then-INSERT/UPDATE on the content- - // addressed PK (blake3_hash). Fetch the existing row's device_id - // AND a cheap equivalence-check column (mime_type or updated_at) - // to determine Unchanged vs Updated. - let existing: Option<(String, Option)> = tx - .query_row( - "SELECT device_id, mime_type FROM file_metadata - WHERE blake3_hash = ?1 AND deleted_at IS NULL", - [&hash_hex], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(Error::from)?; - - let outcome = match existing { - None => { - tx.execute( - "INSERT INTO file_metadata - (blake3_hash, width, height, duration_ms, captured_at, - camera_make, camera_model, codec, bitrate_bps, mime_type, - extracted_at, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12)", - rusqlite::params![ - hash_hex, meta.width, meta.height, meta.duration_ms, - meta.captured_at, meta.camera_make, meta.camera_model, - meta.codec, meta.bitrate_bps, meta.mime_type, - now, dev_str, - ], - ).map_err(Error::from)?; - UpsertOutcome::Inserted - } - Some((existing_dev, existing_mime)) - if existing_dev == dev_str && existing_mime == meta.mime_type => - { - // Cheap equality proxy — mime+device. For full equality - // we'd need to hash the MediaMetadata. v0.4.0 accepts - // false-Updated over false-Unchanged as the safe default. - UpsertOutcome::Unchanged - } - Some(_) => { - tx.execute( - "UPDATE file_metadata - SET width = ?2, height = ?3, duration_ms = ?4, - captured_at = ?5, camera_make = ?6, camera_model = ?7, - codec = ?8, bitrate_bps = ?9, mime_type = ?10, - updated_at = ?11, device_id = ?12 - WHERE blake3_hash = ?1", - rusqlite::params![ - hash_hex, meta.width, meta.height, meta.duration_ms, - meta.captured_at, meta.camera_make, meta.camera_model, - meta.codec, meta.bitrate_bps, meta.mime_type, - now, dev_str, - ], - ).map_err(Error::from)?; - UpsertOutcome::Updated - } - }; - tx.commit() - .map_err(|e| CoreError::Internal(format!("commit: {e}")))?; - Ok(outcome) -} -``` - -`list_with_metadata` SQL — **includes `fm.updated_at` as NULL sentinel** to distinguish "no metadata row" from "row with all-NULL optional fields": - -```sql -SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, - fl.status, fl.first_seen, - fm.updated_at, -- NULL if no file_metadata row at all - fm.width, fm.height, fm.duration_ms, fm.captured_at, - fm.camera_make, fm.camera_model, fm.codec, fm.bitrate_bps, - fm.mime_type -FROM file_locations fl -JOIN files f ON f.blake3_hash = fl.blake3_hash -LEFT JOIN file_metadata fm - ON fm.blake3_hash = fl.blake3_hash - AND fm.deleted_at IS NULL -WHERE fl.deleted_at IS NULL - AND (?1 IS NULL OR fl.volume_id = ?1) -ORDER BY fl.relative_path -LIMIT ?2 -``` - -Deserialize: `fm.updated_at IS NULL` ⇒ `(loc, None)`. Otherwise reconstruct `MediaMetadata { hash: /* from fl.blake3_hash */, width, height, ... }` — the plan's `SELECT` includes `f.blake3_hash` which is the authoritative hash; populate `MediaMetadata.hash` from that column (NOT from a separate `fm.blake3_hash` SELECT). - -- [ ] **Step 4: Run tests → green** - -```bash -cargo test -p perima-db metadata_repo:: 2>&1 | tail -15 -``` - -- [ ] **Step 5: Full gate** - -```bash -just ci -``` - -- [ ] **Step 6: Commit** - -```bash -git commit -m "feat(db): V002 file_metadata migration + SqliteMetadataRepository - -Adds file_metadata table (content-addressed by blake3_hash PK, -matching the files table). All CRDT columns present (updated_at, -device_id, deleted_at). No UNIQUE on mutable columns. Partial index -on captured_at filters WHERE deleted_at IS NULL for space. - -SqliteMetadataRepository uses the BEGIN IMMEDIATE pattern established -in v0.3.1 for race-safe upserts. list_with_metadata uses a single -LEFT JOIN (no N+1). - -Trait uses &self with interior mutability (Mutex), -matching actual Arc-sharing usage. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 3: `perima-media` crate — extractors + queue - -**Files:** -- Create: `crates/media/Cargo.toml` (add to `[workspace.members]`) -- Create: `crates/media/src/lib.rs` -- Create: `crates/media/src/extractor.rs` -- Create: `crates/media/src/queue.rs` -- Create: `crates/media/tests/integration.rs` (runtime fixtures) -- Modify: root `Cargo.toml` `[workspace.dependencies]` — add `image`, `kamadak-exif`, `mp4parse`, `mime_guess`. - -- [ ] **Step 1: Scaffold crate** - -`crates/media/Cargo.toml`: - -```toml -[package] -name = "perima-media" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -perima-core.workspace = true -image.workspace = true -kamadak-exif.workspace = true -mp4parse.workspace = true -mime_guess.workspace = true -tokio.workspace = true -tokio-util.workspace = true -thiserror.workspace = true -tracing.workspace = true - -[dev-dependencies] -tempfile.workspace = true -# mp4 writer (separate from mp4parse reader) — WHY: we need to -# synthesize a minimal valid MP4 at test runtime for VideoExtractor -# tests without committing binary fixtures. -mp4 = "0.14" - -[lints] -workspace = true -``` - -Add to root `Cargo.toml`: - -```toml -# under [workspace.members]: "crates/*" already covers it - -# under [workspace.dependencies]: -image = "0.25" -kamadak-exif = "0.5" -mp4parse = "0.17" -mime_guess = "2" -``` - -- [ ] **Step 2: Author `extractor.rs`** - -`ImageExtractor`: `accepts(mime)` returns true for `image/*`. `extract` uses `image::image_dimensions(path)` for width/height; `kamadak-exif::Reader` for EXIF DateTimeOriginal → ISO 8601 `captured_at`, Make/Model. - -`VideoExtractor`: `accepts(mime)` for `video/mp4`, `video/quicktime`. Uses `mp4parse::read_mp4` to get duration + codec + bitrate. - -`CompositeExtractor`: `Vec>`; `extract` finds first `accepts(mime)` match. - -Doc comments required on every public item. - -- [ ] **Step 3: Runtime-generated test fixtures** - -In `crates/media/tests/integration.rs`, generate fixtures at test time (NOT committed to git): - -```rust -fn make_test_jpeg(width: u32, height: u32, tmp: &Path) -> PathBuf { - use image::{ImageBuffer, Rgb}; - let img: ImageBuffer, Vec> = - ImageBuffer::from_fn(width, height, |_, _| Rgb([255, 0, 0])); - let path = tmp.join("test.jpg"); - img.save(&path).expect("save jpeg"); - path -} - -fn make_test_mp4(tmp: &Path) -> PathBuf { - // WHY programmatic: checking binary fixtures into git bloats the - // repo + pins a toolchain. mp4 crate (dev-dep) constructs a - // minimal valid container at test runtime — 1 track, 1 AVC - // sample, ~1s duration, no audio. - // Exact API: mp4::Mp4Writer::write_start + write_sample + - // write_end. See mp4 crate examples. - todo!("implementer fills in the mp4 crate write sequence") -} -``` - -**JPEG EXIF fixture is harder** — `image` crate does not emit EXIF. -Options, in order of simplicity: - -1. **Preferred:** embed a minimal EXIF-tagged JPEG as a `const &'static [u8]` in the test file (byte-vendored, <2 KB; not a binary file on disk). Generate it ONCE externally via `exiftool -DateTimeOriginal=...` and paste the hex bytes. This is NOT a binary git commit — it's inline Rust source with a WHY comment. -2. Alternative: use the `exif-rs` crate (not kamadak-exif, which is read-only) to splice an APP1 segment into an `image`-encoded JPEG. More code. - -**Pick (1).** Document the source command in a WHY comment so the bytes are reproducible. - -Tests: -- `image_extractor_png_dimensions` — 100×50 PNG, assert width/height. -- `image_extractor_jpeg_exif` — load embedded `const &'static [u8]`, write to tempfile, assert `captured_at` parsed correctly. -- `video_extractor_mp4_duration` — generate via `make_test_mp4`, assert duration > 0. -- `composite_dispatches_by_mime` — 3 mock extractors with different `accepts()`; the right one fires. - -- [ ] **Step 4: Author `queue.rs`** - -Copy spec's `MetadataQueue` sketch verbatim — it's already correct. Add doc comments on every public item. Tests: -- `queue_processes_work` — send 5 items, assert all reach mock repo. -- `queue_exits_on_cancel` — cancel mid-stream. -- `queue_enqueue_returns_on_cancel_while_full` — fill the channel, cancel, assert enqueue returns within 500ms. - -- [ ] **Step 5: Gate + commit** - -```bash -just ci -git commit -m "feat(media): new crate with Image/Video extractors + MetadataQueue - -New perima-media crate houses image + video metadata extraction -(image, kamadak-exif, mp4parse) and the bounded tokio mpsc queue -that drives async processing. Queue's enqueue is sync with -try_send + 50ms poll loop that watches CancellationToken — -keeps Ctrl-C responsive even under queue saturation. - -CompositeExtractor dispatches by MIME (not first-non-empty), -avoiding accident-prone partial-metadata false wins. - -Fixtures generated at test runtime via the image + mp4 crates — -no binary blobs in git. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 4: CLI integration — `perima metadata` + `perima ls --with-metadata` - -**Files:** -- Create: `crates/cli/src/cmd/metadata.rs` -- Modify: `crates/cli/src/cmd/ls.rs` — add `--with-metadata` flag + new table renderer -- Modify: `crates/cli/src/cmd/scan.rs` — enqueue after `upsert_file` succeeds (Inserted or Updated); bounded drain on exit -- Modify: `crates/cli/src/cmd/watch.rs` — queue lives for watch's lifetime (no new enqueue — watcher defers to next scan per spec) -- Modify: `crates/cli/src/main.rs` — register `Metadata` subcommand -- **Modify: `crates/cli/Cargo.toml`** — add `perima-media.workspace = true` to `[dependencies]`. **Required** so `perima`'s dep graph includes `perima-media`; release-plz uses this to propagate version bumps when `feat(media):` commits land. - -- [ ] **Step 1: Add `--with-metadata` flag to `LsArgs`** - -```rust -pub struct LsArgs { - // existing fields… - /// Include media metadata columns (captured_at, dimensions, camera). - #[arg(long)] - pub with_metadata: bool, -} -``` - -Branch in `ls::run` to `print_table_with_metadata` helper when flag set. **Per reviewer nit: separate helper, not branch inside `print_table`.** - -- [ ] **Step 2: `perima metadata `** - -New command that: opens the DB, looks up the file by path, enqueues it for re-extraction, waits (bounded 3s) for the row to appear or update, prints the resulting MediaMetadata. - -- [ ] **Step 3: Integrate queue into `scan::run` with bounded drain** - -After `upsert_file` returns `Inserted` or `Updated`, enqueue `(hash, absolute_path)` using `MetadataQueue::enqueue(&cancel)`. **The worker internally calls `mime_guess::from_path` after dequeue** — scanner does NOT compute MIME, and `Work` struct does NOT carry a `mime` field. This keeps `mime_guess` confined to `perima-media`. - -Queue is owned by `scan::run`. On scan exit: -- Drop the `MetadataQueue` (closes the Sender half of the channel). -- Await the worker's `JoinHandle` with a bounded timeout (default 30s, `--no-wait-metadata` flag bypasses). -- Log progress every 5s: `"metadata queue: N remaining"`. - -**WHY bounded drain** (not fire-and-forget): the integration test + exit criteria assert rows appear after scan. Pure fire-and-forget makes those unprovable because the tokio runtime is dropped when `main` returns. A 30s default drain is long enough for typical scans (<10 files) and short enough that Ctrl-C remains responsive (drain also polls cancel). - -Add to CHANGELOG notes: `perima scan` waits up to 30s post-scan for metadata queue to drain; larger scans may need re-running `perima scan` or `perima metadata ` for stragglers. `--no-wait-metadata` flag skips the drain for users who need fast scan exit. - -- [ ] **Step 4: TDD — integration test** - -`crates/cli/tests/scan_with_metadata_test.rs` — scan a tempdir with 1 PNG + 1 JPEG, wait up to 3s after scan exit, assert `file_metadata` table has 2 rows. - -- [ ] **Step 5: Gate + commit** - -```bash -git commit -m "feat(cli): perima metadata command + ls --with-metadata flag - -New metadata subcommand re-extracts metadata for a specific file -(debugging / one-off fixes). ls gains an optional --with-metadata -flag that adds captured_at + dimensions + camera_model columns -via the new LEFT JOIN query path. Default ls output is unchanged -for v0.3.x script compatibility. - -Scanner now enqueues freshly hashed files into the MetadataQueue -after each successful upsert_file (Inserted or Updated). Scan -return is fire-and-forget; queue drains in the background. - -Watcher does not enqueue on Modified events in v0.4.0 — content -changes mark status=stale, next scan re-extracts. Avoids Modified- -storm pathology during file-in-flight downloads. Watcher-driven -metadata filed for v0.5. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 5: Tauri command — `list_files_with_metadata` - -**Files:** -- Modify: `crates/desktop/src/commands.rs` — add command -- Create: `crates/desktop/src/payloads.rs` — `FileWithMetadataPayload`. **Create; file does NOT currently exist.** Also modify `crates/desktop/src/lib.rs` to `pub mod payloads;`. -- Modify: `crates/desktop/src/state.rs` — add `Arc` to `AppState`. **WHY deviation from per-call-open pattern:** the existing `AppState` deliberately holds no DB repo (commands open per-call for lifetime/Send reasons — see state.rs WHY comment). The metadata repo is different because it must be shared with the `MetadataQueue` worker (long-lived background task). Document this deviation explicitly in state.rs. -- Modify: `crates/desktop/src/lib.rs` — register command in `tauri::generate_handler!` + specta -- **Modify: `crates/desktop/Cargo.toml`** — add `perima-media.workspace = true` + `metadata_repo` in AppState construction path. - -- [ ] **Step 1: Payload type** - -```rust -#[derive(Serialize, specta::Type)] -pub struct FileWithMetadataPayload { - // flatten existing FileEntry fields - pub hash: String, - pub size: u64, - pub volume_id: String, - pub relative_path: String, - pub status: String, - pub first_seen: String, - // metadata - pub width: Option, - pub height: Option, - pub duration_ms: Option, - pub captured_at: Option, - pub camera_make: Option, - pub camera_model: Option, - pub codec: Option, - pub bitrate_bps: Option, - pub mime_type: Option, -} -``` - -- [ ] **Step 2: Command** - -```rust -#[tauri::command] -#[specta::specta] -async fn list_files_with_metadata( - limit: usize, - volume: Option, - state: tauri::State<'_, AppState>, -) -> Result, String> { - // Call metadata_repo.list_with_metadata, convert to payload. -} -``` - -- [ ] **Step 3: Register + test** - -`tauri::generate_handler![scan, list_files, list_volumes, list_files_with_metadata, start_watch, stop_watch, is_watching]`. Add smoke test in `crates/desktop/tests/commands_test.rs`: mock DB, call command, assert non-empty Vec. - -- [ ] **Step 4: Frontend types (v0.4.0 commits; consumed in v0.4.1)** - -Update `apps/desktop/src/types.ts` with `FileWithMetadata` interface mirroring the payload. Update `apps/desktop/src/api.ts` with `listFilesWithMetadata(...)` wrapper. **No UI change in v0.4.0.** - -- [ ] **Step 5: Commit** - -```bash -git commit -m "feat(desktop): list_files_with_metadata Tauri command + TS types - -New command returns joined file-location + media-metadata rows. -specta-typed payload; frontend types added but no UI change — -the grid view consuming this lands in v0.4.1. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 6: Verify release-plz opens v0.4.0 PR - -- [ ] **Step 1: After Task 5 pushes to main, observe release-plz** - -```bash -gh pr list --search "release-plz" --json number,title,headRefName --jq '.[0]' -``` - -Expected: a release PR opened proposing `v0.4.0` (minor bump from v0.3.2 since accumulated feats). - -- [ ] **Step 2: Sanity-check the release PR** - -The PR should: -- Bump `Cargo.toml` workspace version 0.3.2 → 0.4.0 -- Append to `CHANGELOG.md` with all the feat commits grouped -- Close #10 if referenced in any commit (add "Closes #10" to the Task 3 media commit retroactively if missed) - -**`apps/desktop/package.json` bump is MANUAL** — release-plz doesn't cover Node package files. As part of merging the release PR, land a follow-up commit `chore(release): bump apps/desktop/package.json to 0.4.0` **before** the release-plz tag job runs. Sequence: -1. Merge release PR (updates Cargo.toml + CHANGELOG). -2. Immediately push the package.json bump commit. -3. release-plz's release job tags the latest HEAD on main → tag includes both bumps. - -**If the CHANGELOG looks wrong or version is wrong:** don't merge. Fix the config or the commits, push a revision. - -- [ ] **Step 3: Merge → tag → GH Release auto** - -Merge via `gh pr merge --squash` (squash to keep main history clean) OR `gh pr merge --merge` (preserve individual commits). **Recommend squash** so the release commit is a single clean changelog entry. - -release-plz's second workflow job (release) then creates the tag + GH Release automatically on main push. - ---- - -## Exit criteria - -- [ ] V002 migration runs cleanly on existing v0.3.x databases (test via `cargo test -p perima-db`). -- [ ] `just ci` green throughout the task sequence. -- [ ] release-plz opens `v0.4.0` release PR after Task 5 pushes. -- [ ] PR merges → tag `v0.4.0` lands → GH Release created. -- [ ] `perima scan` over a 50-file corpus produces 50 file_metadata rows within 5 seconds after scan exit. -- [ ] `perima ls --with-metadata` prints the new columns. -- [ ] `perima metadata ` re-extracts and prints. -- [ ] No regressions: v0.3.x tests (34 db + 11 ts) still pass. -- [ ] #10 (release-plz) closed after v0.4.0 tag. - ---- - -## Risks - -- **release-plz proposes wrong version.** Dry-run locally before pushing Task 1 (`release-plz release-pr --dry-run`). If wrong, fix config. -- **`image-webp` quality.** Relevant in v0.4.1 only (no thumbnails yet). -- **`mp4parse` doesn't cover all video formats.** Acceptable for v0.4.0 scope (mp4/mov only); others return empty metadata. -- **Queue back-pressure.** 16384 cap; worst case scanner sleeps 50ms loops. Document throughput post-first-scan. -- **`FileRepository` trait `&mut self` mismatch.** File a v0.5.x issue to align the trait. - ---- - -## Self-review - -**Spec coverage:** -- Core types + trait → Task 1 ✓ -- DB migration + impl → Task 2 ✓ -- Media crate → Task 3 ✓ -- CLI integration → Task 4 ✓ -- Tauri command → Task 5 ✓ -- Release automation → Task 6 (via release-plz from v0.3.3) ✓ - -**Placeholder scan:** concrete code blocks for the non-obvious patterns (BEGIN IMMEDIATE upsert, try_send loop, LEFT JOIN SQL). Task 3's `make_test_mp4` is a placeholder intentionally — implementer should pick the `mp4` crate version that works. - -**Type consistency:** `MetadataRepository::&self` throughout; `list_with_metadata` returns `Vec<(FileLocationRecord, Option)>` everywhere; `try_send` used, not `blocking_send`; V002 uses `blake3_hash PRIMARY KEY`. - -**Commit discipline:** five feature commits, each with its own reviewer pass. release-plz cuts the release on Task 6 merge. diff --git a/docs/superpowers/plans/2026-04-16-v0.4.1-thumbnails-grid.md b/docs/superpowers/plans/2026-04-16-v0.4.1-thumbnails-grid.md deleted file mode 100644 index 700169b..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.4.1-thumbnails-grid.md +++ /dev/null @@ -1,419 +0,0 @@ -# v0.4.1 — Thumbnails + grid view Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. - -**Goal:** Ship WebP thumbnails generated by the MetadataQueue worker + a -desktop grid view that displays them. User-visible media finally lands. - -**Architecture:** `ThumbnailGenerator` in `perima-media` writes atomically -(`.tmp` + rename) to `/thumbnails//.webp`. V003 -migration adds `thumbnail_path` + `thumbnail_status` to `file_metadata`. -Queue worker extension: after metadata extraction, if `kind in -(image|video)`, call the thumbnailer; update status on success/failure. -React FileGrid consumes `list_files_with_metadata`, renders 200px tiles -via Tauri asset protocol (`convertFileSrc`). - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md` v0.4.1 section. - -**Release mechanism:** Same as v0.4.0 — feat commits land, then a manual `chore(release): v0.4.1` commit bumps versions. release-plz's `release` job auto-creates tag + GH Release. - -**Env prefix (every cargo/just/bun):** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - ---- - -## Task 1 — `feat(media): WebP thumbnail generator + V003 schema` - -**Files:** -- Create: `crates/media/src/thumbnail.rs` -- Modify: `crates/media/src/lib.rs` — `pub mod thumbnail;` -- Create: `crates/db/migrations/V003__file_metadata_thumbnails.sql` -- Modify: `crates/core/src/metadata.rs` — add `thumbnail_path: Option` + `thumbnail_status: Option` to `MediaMetadata` -- Modify: `crates/db/src/metadata_repo.rs` — update `upsert_metadata` + `list_with_metadata` to carry the new columns - -### Subtasks - -- [ ] **Step 1: V003 migration** - -```sql --- crates/db/migrations/V003__file_metadata_thumbnails.sql --- WHY DEFAULT 'pending': existing v0.4.0 rows auto-classify as --- "awaiting thumbnail generation" on migration, matching spec line --- 422. SQLite permits constant-string DEFAULT on ADD COLUMN. -ALTER TABLE file_metadata ADD COLUMN thumbnail_path TEXT; -ALTER TABLE file_metadata - ADD COLUMN thumbnail_status TEXT NOT NULL DEFAULT 'pending'; - --- Partial index for "rows awaiting thumbnail generation" — supports --- a future `perima thumbnail` retry command. -CREATE INDEX idx_file_metadata_thumbnail_pending - ON file_metadata(blake3_hash) - WHERE thumbnail_status = 'pending' AND deleted_at IS NULL; -``` - -- [ ] **Step 2: Extend `MediaMetadata`** - -Add two `Option` fields. Preserve backwards-compat — defaulting -NULL means "not attempted yet" for existing rows (V003 ALTER adds NULL -columns). - -- [ ] **Step 3: `ThumbnailGenerator`** - -```rust -// crates/media/src/thumbnail.rs -use std::path::{Path, PathBuf}; -use image::{imageops::FilterType, ImageReader}; -use perima_core::{BlakeHash, CoreError}; - -pub struct ThumbnailGenerator { - data_dir: PathBuf, - max_size: u32, // default 256 -} - -impl ThumbnailGenerator { - pub fn new(data_dir: PathBuf) -> Self { Self { data_dir, max_size: 256 } } - - /// `/thumbnails//.webp` - pub fn path_for(&self, hash: &BlakeHash) -> PathBuf { - let hex = hash.to_hex(); - let (prefix, _) = hex.split_at(2); - self.data_dir.join("thumbnails").join(prefix).join(format!("{hex}.webp")) - } - - /// Decode, resize (Lanczos3, preserve aspect), encode WebP q=85, - /// atomic write via `.tmp` + rename. No-op if target exists. - pub fn generate(&self, hash: &BlakeHash, source: &Path) -> Result { - let target = self.path_for(hash); - if target.exists() { return Ok(target); } - - std::fs::create_dir_all(target.parent().unwrap()) - .map_err(|e| CoreError::Internal(format!("mkdir thumbnail: {e}")))?; - - let img = ImageReader::open(source) - .map_err(|e| CoreError::Internal(format!("open source: {e}")))? - .with_guessed_format() - .map_err(|e| CoreError::Internal(format!("guess format: {e}")))? - .decode() - .map_err(|e| CoreError::Internal(format!("decode: {e}")))?; - - let resized = img.resize(self.max_size, self.max_size, FilterType::Lanczos3); - - // WHY atomic write: without .tmp + rename a crash mid-encode - // leaves a half-written .webp that passes the exists() check - // forever. rename is atomic on POSIX (within same filesystem). - let tmp = target.with_extension("webp.tmp"); - let mut buf = std::fs::File::create(&tmp) - .map_err(|e| CoreError::Internal(format!("create tmp: {e}")))?; - resized - .write_to(&mut buf, image::ImageFormat::WebP) - .map_err(|e| CoreError::Internal(format!("encode webp: {e}")))?; - drop(buf); - - std::fs::rename(&tmp, &target) - .map_err(|e| CoreError::Internal(format!("rename: {e}")))?; - Ok(target) - } -} -``` - -- [ ] **Step 4: Unit tests** - -- `path_for_contains_hash_prefix` — 2-char dir + full-hex filename. -- `generate_produces_256_max_dim_preserving_aspect` — 1000×500 source → 256×128 out. -- `generate_idempotent` — call twice, second is a no-op. -- `generate_atomic_write_no_tmp_remains_on_success` — after successful call, no `.tmp` file left in the target dir. - -- [ ] **Step 5: Extend `SqliteMetadataRepository`** (FIVE sites, not two) - -The column list needs updating at ALL of these callsites: -1. `upsert_metadata` INSERT statement + param list. -2. `upsert_metadata` UPDATE statement + param list. -3. `find_by_hash` SELECT + deserialization tuple. -4. `list_with_metadata` SELECT + deserialization tuple. -5. `MetadataRowCols` type alias (currently 9-tuple; extends to 11). - -Also: **add a dedicated `update_thumbnail(&self, hash, path: Option, status: String, device: DeviceId)` inherent method**. The queue worker calls this AFTER upsert_metadata returns, so the Unchanged-vs-Updated equivalence proxy in upsert_metadata doesn't double-write when thumbnail state flips pending→ready. Decoupled write, cleaner semantics. Include BEGIN IMMEDIATE wrapping matching v0.3.1 pattern. - -Tests: update all existing upsert_metadata tests to pass default `thumbnail_path: None`, `thumbnail_status: Some("pending".into())` (NOT NULL per V003). Add 2 new tests: -- `update_thumbnail_marks_ready_with_path` -- `update_thumbnail_marks_failed_keeps_path_none` - -**Extend `FileWithMetadataPayload`** in `crates/desktop/src/payloads.rs`: -- Add `thumbnail_path: Option` + `thumbnail_status: Option` fields. -- Update `From<(FileLocationRecord, Option)>` impl to copy them through. -- Regenerate specta TS bindings if not automatic. - -- [ ] **Step 6: Gate + commit** - -```bash -just ci -git add crates/media/src/thumbnail.rs crates/media/src/lib.rs \ - crates/core/src/metadata.rs crates/db/ -git commit -m "feat(media): WebP thumbnail generator + V003 schema - -- ThumbnailGenerator writes 256px WebP (q=85, Lanczos3) atomically - via .tmp + rename to /thumbnails//.webp. - Idempotent exists() check + rename semantics make partial writes - impossible. -- V003 migration adds thumbnail_path + thumbnail_status columns to - file_metadata (nullable; ALTER TABLE is additive). Partial index - on thumbnail_status = 'pending' supports future retry command. -- MediaMetadata + SqliteMetadataRepository carry the new fields - through. -- 4 new thumbnail tests + existing metadata tests updated for new - columns. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -git push origin main -``` - ---- - -## Task 2 — `feat(cli,media): queue worker generates thumbnails + --no-thumbnails flag` - -**Files:** -- Modify: `crates/media/src/queue.rs` — worker now calls thumbnailer after extractor -- Modify: `crates/cli/src/cmd/scan.rs` — pass `ThumbnailGenerator` into queue spawn + new flag - -### Subtasks - -- [ ] **Step 1: Extend `MetadataQueue::spawn`** - -Signature grows by one arg: `thumbnailer: Arc`. Call sites to update: -- `crates/cli/src/cmd/scan.rs::run` — build + pass thumbnailer -- `crates/cli/src/cmd/watch.rs::run` — if it spawns a queue (check; watcher currently defers metadata to next scan per spec, so may not need) -- `crates/desktop/src/lib.rs::run` — if it spawns a queue (currently no; only the repo is in AppState) -- `crates/media/src/queue.rs` tests — mock thumbnailer -- `crates/cli/tests/scan_with_metadata_test.rs` — integration test wiring - -Worker `process` fn: -1. Extract metadata (existing). -2. Call `repo.upsert_metadata(&meta, device)` — metadata row written. -3. If metadata's kind is image or video, call `thumbnailer.generate(hash, source_path)`: - - Success → `repo.update_thumbnail(hash, Some(absolute_path_string), "ready", device)`. - - Failure → `repo.update_thumbnail(hash, None, "failed", device)`. Log; don't abort. -4. **Store ABSOLUTE path** in `thumbnail_path`. Tauri's `convertFileSrc` needs absolute paths; a relative path won't resolve in the WebView. Relative-path storage is trickier (requires every frontend read to resolve against data_dir); absolute is simpler + matches current design decisions elsewhere. WHY comment. - -### Tauri asset protocol scope - -`crates/desktop/tauri.conf.json` must allow reading the thumbnails directory. Add under `app.security`: - -```json -{ - "app": { - "security": { - "assetProtocol": { - "enable": true, - "scope": ["$APPDATA/perima/thumbnails/**"] - } - } - } -} -``` - -`$APPDATA/perima` is the Tauri variable equivalent of our `data_dir` (OS-appropriate: Linux `~/.local/share/perima`, macOS `~/Library/Application Support/perima`, Windows `%APPDATA%/perima`). Verify the Tauri scope variable matches what `directories::ProjectDirs` resolves to at runtime; if mismatch, use `**` with a broader path. - -- [ ] **Step 2: Scan wiring** - -Add `no_thumbnails: bool` field to `ScanArgs` struct (line ~30 in scan.rs) with `#[arg(long)]`. - -`ThumbnailGenerator` gains an `enabled: bool` flag with `disabled()` constructor: - -```rust -impl ThumbnailGenerator { - pub fn new(data_dir: PathBuf) -> Self { Self { data_dir, max_size: 256, enabled: true } } - pub fn disabled() -> Self { Self { data_dir: PathBuf::new(), max_size: 0, enabled: false } } - pub fn generate(&self, hash: &BlakeHash, source: &Path) -> Result, CoreError> { - if !self.enabled { return Ok(None); } - // … existing body, returning Ok(Some(target)) on success - } -} -``` - -WHY `enabled` flag inline rather than a trait: trait-based polymorphism (a `NoopThumbnailer` struct) is more testable but the flag is a single bool — cheapest solution for v0.4.1. Trait migration filed as v0.5 follow-up. - -`scan::run` builds `ThumbnailGenerator::new(config.data_dir.join("thumbnails"))` or `::disabled()` depending on `args.no_thumbnails`. Passes into `MetadataQueue::spawn`. - -- [ ] **Step 3: Integration test** - -Extend `scan_with_metadata_test`: -- After scan, assert thumbnails exist at `/thumbnails//.webp` for each PNG/JPEG. -- Assert `thumbnail_status = 'ready'` in the DB rows. - -- [ ] **Step 4: Gate + commit** - -``` -feat(cli,media): queue worker generates WebP thumbnails - -The MetadataQueue worker now calls ThumbnailGenerator after -extracting metadata. On success, thumbnail_path + thumbnail_status -= 'ready' are persisted; on failure, status = 'failed' (no abort; -metadata row still upserted). - -New `perima scan --no-thumbnails` flag shortcircuits via a no-op -thumbnailer for users who want metadata-only or faster scans. - -Integration test extended to assert WebP files appear on disk and -DB status flips to 'ready'. -``` - ---- - -## Task 3 — `feat(desktop): FileGrid component + view-mode toggle` - -**Files:** -- Create: `apps/desktop/src/components/FileGrid.tsx` -- Modify: `apps/desktop/src/App.tsx` — view-mode state + toggle UI + conditional render -- Modify: `apps/desktop/src/types.ts` / `api.ts` — already done in v0.4.0; just confirm thumbnail fields are reachable -- Create: `apps/desktop/src/__tests__/FileGrid.test.tsx` - -### Subtasks - -- [ ] **Step 0: Add thumbnail fields to TS `FileWithMetadata`** - -In `apps/desktop/src/types.ts`, extend the `FileWithMetadata` interface with `thumbnail_path: string | null` + `thumbnail_status: string | null`. Matches the Rust `FileWithMetadataPayload` extension from Task 1. - -- [ ] **Step 1: `FileGrid.tsx`** - -```tsx -import { convertFileSrc } from "@tauri-apps/api/core"; -import type { FileWithMetadata } from "../types"; - -interface FileGridProps { - files: FileWithMetadata[]; -} - -/** - * Grid view of indexed files with thumbnails. - * - * WHY naive layout: file counts are < 10k in v0.4.1 test corpora. - * Virtualization (react-window) lands in v0.5 once real libraries - * stress the naive render. - */ -export default function FileGrid({ files }: FileGridProps) { - return ( -
- {files.map((f) => ( - - ))} -
- ); -} - -function FileGridTile({ file }: { file: FileWithMetadata }) { - const ready = file.thumbnail_status === "ready" && file.thumbnail_path; - return ( -
-
- {ready ? ( - {file.relative_path} - ) : ( - - )} -
-
- {file.relative_path.split("/").pop()} -
-
- ); -} - -function PlaceholderIcon({ status }: { status: string | null | undefined }) { - // Pending = spinner icon; Failed = error icon; null = unknown type - return ( -
- {status === "failed" ? "⚠" : status === "pending" ? "…" : "?"} -
- ); -} -``` - -- [ ] **Step 2: View-mode toggle in App.tsx** - -Add `viewMode` state (`'table' | 'grid'`, default `'table'` per spec). Header button toggles; main area conditionally renders `` or ``. Load via `listFilesWithMetadata(100)` so grid has thumbnail paths. - -- [ ] **Step 3: Vitest coverage** - -`FileGrid.test.tsx`: 3 tiles (ready with path, pending, failed). Assert image element for ready; placeholder text for other states. - -- [ ] **Step 4: Gate** - -```bash -cd apps/desktop && bun run test && bun run lint && bun run build && cd - -just ci -``` - -Expected: 12 TS tests (11 existing + 1 new FileGrid suite) + all Rust green. - -- [ ] **Step 5: Commit + push** - -``` -feat(desktop): FileGrid + view-mode toggle - -New grid view with 200px tiles. Uses Tauri's convertFileSrc -to load thumbnails from disk. Placeholder icons for pending/failed/ -unknown-type rows. - -App.tsx gains a Table|Grid toggle in the header; default is Table -(v0.3.x UX continuity). - -New vitest covers the three thumbnail states. -``` - ---- - -## Task 4 — `chore(release): v0.4.1` (trigger auto-tag) - -- [ ] **Step 1: Bump versions** - -Cargo.toml workspace 0.4.0 → 0.4.1. -Workspace dep `perima-media = { path = ..., version = "0.4.0" }` → `"0.4.1"`. -`apps/desktop/package.json` 0.4.0 → 0.4.1. - -- [ ] **Step 2: CHANGELOG.md append v0.4.1 section** - -Cover: WebP thumbnails, V003 schema, FileGrid + toggle, new flag `--no-thumbnails`. Link `[0.4.1]: https://github.com/utof/perima/releases/tag/v0.4.1`. - -- [ ] **Step 3: Gate + commit** - -```bash -just ci -git commit -m "chore(release): v0.4.1 — thumbnails + grid view" -git push origin main -``` - -release-plz's `release` job auto-creates the tag + GH Release on push. - ---- - -## Exit criteria - -- [ ] V003 migration runs cleanly on a v0.4.0 database (idempotent with refinery). -- [ ] `just ci` green throughout. -- [ ] Thumbnails exist on disk after scan (`/thumbnails//.webp`). -- [ ] Desktop grid renders thumbnails; placeholder for pending/failed. -- [ ] release-plz auto-tags v0.4.1 from chore(release) push. -- [ ] No regressions in v0.4.0 tests. - -## Risks - -- **`image` crate WebP quality.** Pure-Rust encoder is inferior to libwebp at same bitrate. For 256px thumbnails it's acceptable. Filed GH issue if user complaints arrive. -- **Thumbnail disk space.** 100k images × ~20 KB = 2 GB. Acceptable; GC deferred. -- **V003 on a v0.4.0 DB.** `ALTER TABLE ADD COLUMN` is safe in SQLite for nullable columns. Refinery applies V003 in order. Existing v0.4.0 rows end up with `thumbnail_path = NULL, thumbnail_status = NULL` — correctly flagged as "not yet processed" for the `perima process` retry path (deferred) or next `perima scan`. - -## Self-review - -**Spec coverage:** Task 1 covers thumbnailer + schema; Task 2 covers queue integration; Task 3 covers grid UI; Task 4 cuts release. Spec's v0.4.1 scope fully mapped. - -**Placeholder scan:** no TBD in substantive steps. - -**Type consistency:** `thumbnail_path` + `thumbnail_status` both `Option` in Rust, `string | null` in TS (matching existing Option serialization). diff --git a/docs/superpowers/plans/2026-04-16-v0.4.2-hotfix.md b/docs/superpowers/plans/2026-04-16-v0.4.2-hotfix.md deleted file mode 100644 index 6a7c2d5..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.4.2-hotfix.md +++ /dev/null @@ -1,329 +0,0 @@ -# v0.4.2 Hotfix Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. - -**Goal:** Ship 5 fixes from codex:rescue adversarial review of phase 4 (utof/perima#15). Must land before any phase 5 work. - -**Spec-equivalent:** utof/perima#15 body enumerates every fix with concrete file:line pointers. Treat that as the spec. - -**Env:** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - -**Release mechanism:** 5 feat/fix commits + a `chore(release): v0.4.2` commit. release-plz's `release` job auto-tags on `chore(release):` (proven on v0.4.0 + v0.4.1). - ---- - -## Task 1: `fix(desktop): narrow Tauri asset protocol scope` (CRIT) - -**File:** `crates/desktop/tauri.conf.json` - -- [ ] **Step 1:** Remove the `**` wildcard from the `assetProtocol.scope`. Keep only the thumbnails-specific entry. If `$APPDATA/perima/thumbnails/**` doesn't resolve on Linux (which doesn't have `$APPDATA`), use platform-appropriate path via Tauri's `$HOME/.local/share/perima/thumbnails/**` OR drop to `$APPCONFIG/**` / `$APPDATA/**`. - -Recommended scope: -```json -"assetProtocol": { - "enable": true, - "scope": [ - "$APPDATA/perima/thumbnails/**", - "$APPLOCALDATA/perima/thumbnails/**" - ] -} -``` - -Both variables are Tauri path variables that resolve to OS-appropriate roots (macOS: `~/Library/Application Support/perima/thumbnails`, Linux: `~/.local/share/perima/thumbnails`, Windows: `%APPDATA%/perima/thumbnails`). - -- [ ] **Step 2:** Runtime verification: manual note in commit — unable to launch desktop app in this dev env without a display, so we can't smoke-test. Reviewer / user validates on their workstation post-release. - -- [ ] **Step 3:** Gate + commit. - -```bash -just ci -git commit -m "fix(desktop): narrow Tauri asset protocol scope (security) - -Drop the \`**\` wildcard fallback from assetProtocol.scope. The -wildcard effectively granted the WebView read access to any file -on disk via convertFileSrc — any compromised renderer JS could -exfiltrate arbitrary local files. - -Replaced with explicit thumbnails-subdir scopes using Tauri's -portable path variables (\$APPDATA, \$APPLOCALDATA). OS-appropriate -paths: macOS ~/Library/Application Support/perima/thumbnails, -Linux ~/.local/share/perima/thumbnails, Windows %APPDATA%/perima/ -thumbnails. - -Runtime verification deferred to user testing — no display -available in the CI / dev machine. - -Refs utof/perima#15 (CRIT finding from codex:rescue phase 4 audit). - -Co-Authored-By: Claude Opus 4.6 (1M context) " -``` - ---- - -## Task 2: `fix(db): stop upsert_metadata from clobbering thumbnail columns` (HIGH) - -**File:** `crates/db/src/metadata_repo.rs` - -- [ ] **Step 1:** Remove `thumbnail_path` + `thumbnail_status` from: - - INSERT column list + VALUES tuple (~lines 240-270) - - UPDATE SET clause + param list (~lines 285-320) - - Keep them in `find_by_hash` SELECT + `list_with_metadata` SELECT (read paths are fine) - - Keep `update_thumbnail` intact — it remains the sole writer of thumbnail columns - -- [ ] **Step 2:** Update the `MetadataRowCols` type alias if it's used for the INSERT param list (it probably isn't — that alias is for the find_by_hash tuple only). Confirm. - -- [ ] **Step 3:** Remove `thumbnail_path` + `thumbnail_status` from `MediaMetadata` struct? No — keep them in the domain type (they're part of the persisted row shape). Just stop writing them via upsert_metadata. - -- [ ] **Step 4:** TDD regression test. In `crates/db/src/metadata_repo.rs` tests: - -```rust -#[test] -fn upsert_metadata_preserves_thumbnail_state() { - let (_td, repo) = metadata_repo(); - let dev = device(); - let hash = BlakeHash::from_bytes(*blake3::hash(b"preserve").as_bytes()); - let mut meta = sample_metadata(hash); - repo.upsert_metadata(&meta, dev).expect("initial upsert"); - - // Simulate thumbnail completion. - repo.update_thumbnail(&hash, Some("/tmp/t.webp"), "ready", dev) - .expect("update_thumbnail"); - - // A later metadata upsert with a new mime (forces Updated path) - // must NOT clear the thumbnail_path / status. - meta.mime_type = Some("image/jpeg".into()); - repo.upsert_metadata(&meta, dev).expect("second upsert"); - - let got = repo.find_by_hash(&hash).expect("find").expect("present"); - assert_eq!(got.thumbnail_path.as_deref(), Some("/tmp/t.webp"), - "upsert_metadata must not clear thumbnail_path"); - assert_eq!(got.thumbnail_status.as_deref(), Some("ready"), - "upsert_metadata must not clear thumbnail_status"); -} -``` - -- [ ] **Step 5:** Gate + commit. - -``` -fix(db): decouple upsert_metadata from thumbnail columns - -upsert_metadata previously wrote thumbnail_path + thumbnail_status -from the MediaMetadata struct, which every extractor supplies as -None. A subsequent Updated upsert on an already-thumbnailed row -therefore cleared the state back to NULL, silently losing the -thumbnail association. - -Remove thumbnail columns from INSERT + UPDATE statements. -update_thumbnail is now the sole writer. Read paths (find_by_hash, -list_with_metadata) unchanged — they still surface the thumbnail -state for the UI. - -Regression test pins the new behavior: upsert → update_thumbnail → -upsert-with-changed-mime must preserve thumbnail columns. - -Refs utof/perima#15 (HIGH #4). -``` - ---- - -## Task 3: `fix(media): gate thumbnail generation to image MIMEs` (HIGH) - -**File:** `crates/media/src/queue.rs` - -- [ ] **Step 1:** Find the worker's routing logic (~lines 195-203). Current code routes both `image/*` and `video/*` into `ThumbnailGenerator::generate`. Change to: only `image/*` routes through the thumbnailer; `video/*` gets `update_thumbnail(&hash, None, "skipped", device)` (new status variant). - -Or: don't call `update_thumbnail` at all for videos; leave NULL. Prefer explicit "skipped" so the UI can show a video-icon placeholder. - -Decision: use `"skipped"` status, reuse the existing FileGrid placeholder logic (shows unknown-icon for unfamiliar statuses). This is a meaningful UX signal. - -- [ ] **Step 2:** Unit test in `queue.rs` tests: - -```rust -#[tokio::test] -async fn worker_skips_thumbnail_for_video() { - // Construct a MockRepo that records update_thumbnail calls. - // Send a Work item with an .mp4 file. - // Assert update_thumbnail was called with path=None, status="skipped". - // Assert ThumbnailGenerator::generate was NOT called. -} -``` - -Either use the existing MockRepo with an extra recording vec, or assert via the real SqliteTagRepository + querying. - -- [ ] **Step 3:** Gate + commit. - -``` -fix(media): do not route video files to image thumbnailer - -The queue worker sent both image/* and video/* MIMEs through -ThumbnailGenerator, which decodes via image::ImageReader and cannot -handle MP4/MOV. Every video file got thumbnail_status='failed' — -misleading for users whose video tooling is just out of scope. - -Gate the thumbnail path to image/* only. Video files get -thumbnail_status='skipped' (new status variant, distinct from -failed); the UI placeholder renders a video icon. - -Video frame extraction via ffmpeg is a separate future enhancement; -'skipped' is stable state that won't flap. - -Refs utof/perima#15 (HIGH #11b). -``` - ---- - -## Task 4: `fix(desktop): wire metadata queue + thumbnailer into scan command` (HIGH) - -**File:** `crates/desktop/src/commands.rs` - -- [ ] **Step 1:** Locate the desktop `scan` command + its `run_scan_live()` helper (~lines 265-314, 657-744). Currently builds file_repo + volume_repo only. - -- [ ] **Step 2:** Mirror `crates/cli/src/cmd/scan.rs::run`'s metadata wiring: - - Construct `MetadataQueue` at scan start with `Arc` (already in AppState), `ImageExtractor + VideoExtractor + CompositeExtractor`, `ThumbnailGenerator::new(config.data_dir)`, and a fresh `CancellationToken`. - - After each `upsert_file` returning `Inserted` or `Updated`, enqueue `(hash, absolute_path, &cancel)`. - - At scan exit, drop the queue + await the worker with bounded 30s timeout. - - Honor a hypothetical `no_thumbnails: bool` arg if the scan command takes one (it probably doesn't in the Tauri interface; skip). - -- [ ] **Step 3:** Integration smoke test in `crates/desktop/tests/commands_test.rs`: - -```rust -#[tokio::test] -async fn desktop_scan_populates_metadata_and_thumbnails() { - // Build tempdir with 2 PNG files. - // Call scan command via run_scan_inner (test helper). - // Assert file_metadata has 2 rows + thumbnail files exist on disk. -} -``` - -- [ ] **Step 4:** Gate + commit. - -``` -fix(desktop): wire metadata queue + thumbnailer into scan - -The desktop scan command only touched file_repo + volume_repo; no -MetadataQueue, no ThumbnailGenerator. Users scanning via the UI -got indexed files but no metadata + no thumbnails — phase 4 was -half-shipped through the desktop app. - -Mirror the CLI's scan wiring: construct queue + thumbnailer at -scan start; enqueue after each upsert_file; bounded 30s drain at -exit. - -New integration test pins the end-to-end: desktop scan of a -2-file tempdir produces 2 file_metadata rows + 2 thumbnail files -on disk. - -Refs utof/perima#15 (HIGH #11a). -``` - ---- - -## Task 5: `fix(db): V005 backfill NULL thumbnail_status to 'pending'` (HIGH) - -**Files:** -- Create: `crates/db/migrations/V005__thumbnail_status_pending_backfill.sql` -- Modify: `crates/media/src/queue.rs` — ensure new rows get status='pending' on first insert (via upsert_metadata? no — we just removed that. So the worker must set it explicitly via update_thumbnail before starting extraction.) - -- [ ] **Step 1:** Author V005: - -```sql --- V005: backfill existing NULL thumbnail_status rows to 'pending'. --- --- WHY: V003 added thumbnail_status as nullable without a default, --- and no writer produces 'pending'. Rows created under v0.4.0- --- v0.4.1 therefore stick at NULL forever, excluded from the --- idx_file_metadata_thumbnail_pending partial index. -UPDATE file_metadata - SET thumbnail_status = 'pending', updated_at = ?1 - WHERE thumbnail_status IS NULL AND deleted_at IS NULL; -``` - -**Problem:** refinery migrations are pure SQL, no Rust-callable param binding. Use a literal timestamp OR defer the `updated_at` bump (acceptable — it's a data migration, not a user write). - -Revised: -```sql -UPDATE file_metadata - SET thumbnail_status = 'pending' - WHERE thumbnail_status IS NULL AND deleted_at IS NULL; -``` - -`updated_at` is NOT bumped on migration — document in the migration comment. This deviates from CRDT rule "every mutable row write updates updated_at" but is acceptable for one-time backfill since no user data is actually changing (the semantic is "this row was always pending, we're just labeling it so"). - -- [ ] **Step 2:** Ensure future rows get 'pending' on insert. When the queue worker starts processing a work item, BEFORE extraction, call `update_thumbnail(&hash, None, "pending", device)`. This creates the metadata row in pending state; upon success, a subsequent `update_thumbnail` transitions to 'ready'/'failed'/'skipped'. - -Wait — `update_thumbnail` only updates existing rows. For first-time insertion we need upsert semantics. Either: -- (a) Make `update_thumbnail` upsert-able (SELECT-then-INSERT/UPDATE in Rust); OR -- (b) Let `upsert_metadata` continue to INSERT with `thumbnail_status = 'pending'` on fresh rows, but UPDATE never touches it. - -Option (b) is more aligned with Task 2's decoupling direction and requires smaller surgery. - -Revised Task 2 side-effect: INSERT statement in `upsert_metadata` gets `thumbnail_status = 'pending'` added back as a LITERAL (not from the struct field); UPDATE statement does NOT touch it. - -- [ ] **Step 3:** Test: open an existing v0.4.1 DB file (snapshot into a tempdir), run V005, assert NULL rows become 'pending'. - -```rust -#[test] -fn v005_backfills_null_thumbnail_status_to_pending() { - // Build tempdir with a test DB at V003-state. - // Insert a file_metadata row with thumbnail_status = NULL. - // Run open_and_migrate (applies V005). - // Assert status is now 'pending'. -} -``` - -- [ ] **Step 4:** Gate + commit. - -``` -fix(db): V005 backfill NULL thumbnail_status to 'pending' + insert default - -V003 added thumbnail_status as nullable. No writer ever produced -'pending' — the worker only writes 'ready'/'failed' and the -extractor supplied None. Rows predating the queue or created under ---no-thumbnails therefore stuck at NULL forever, invisible to the -partial index. - -V005 one-shot backfills NULL rows to 'pending'. Future rows get -'pending' on insert via upsert_metadata (literal default in the -INSERT statement — not from struct; UPDATE path still doesn't -touch the column per Task 2 decoupling). - -Refs utof/perima#15 (HIGH #3). -``` - ---- - -## Task 6: `chore(release): v0.4.2 — phase 4 hotfix` - -- [ ] Cargo.toml workspace version 0.4.1 → 0.4.2; perima-media dep same. -- [ ] apps/desktop/package.json 0.4.1 → 0.4.2. -- [ ] CHANGELOG append `## [0.4.2]` section listing the 5 fixes + closing utof/perima#15. -- [ ] `just ci`. -- [ ] Commit `chore(release): v0.4.2 — phase 4 hotfix` + push. -- [ ] release-plz `release` job auto-tags + auto-creates GH Release. -- [ ] Update release title for consistency. -- [ ] Close #15 via commit message or inline comment. - ---- - -## Exit criteria - -- [ ] All 5 CRIT/HIGH findings from codex:rescue resolved. -- [ ] `just ci` green throughout. -- [ ] v0.4.2 tagged + GH Release created by release-plz. -- [ ] #15 closed. -- [ ] Phase 5a unblocked — resume v0.5.0 implementation. -- [ ] No regression in v0.4.x tests (still green). - -## Self-review - -**Scope match with #15:** all 5 CRIT/HIGH items covered as separate commits (T1-T5); HIGH #2 (TOCTOU), MED, LOW items deferred per triage comment. - -**Placeholder scan:** only Task 5 Step 2 is slightly hand-wavy on the "update_thumbnail upsert vs INSERT literal default" design — resolved inline (option b: INSERT literal, UPDATE untouched). Implementer should follow exactly. - -**Commit discipline:** 5 fix commits + 1 chore(release). Each commit is a single logical fix with its own regression test where applicable. release-plz tags on the chore. diff --git a/docs/superpowers/plans/2026-04-16-v0.5-tags.md b/docs/superpowers/plans/2026-04-16-v0.5-tags.md deleted file mode 100644 index 16091ff..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.5-tags.md +++ /dev/null @@ -1,570 +0,0 @@ -# v0.5.x — Tags Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. - -**Goal:** Ship tag-based organization. v0.5.0 = backend (tags + file_tags tables, TagRepository, CLI `tag add/rm/ls`). v0.5.1 = desktop UI (chips, sidebar filter). Both release via release-plz auto-tag on `chore(release):` commits (pattern proven on v0.4.x). - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.5-tags-design.md` (revised post-reviewer). - -**Env prefix:** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - ---- - -## Real-code anchors - -- `crates/core/src/lib.rs` + `ports/mod.rs` — module registration pattern -- `crates/core/src/errors.rs` — add `InvalidTag(String)` alongside existing variants -- `crates/core/src/types.rs` — BlakeHash, DeviceId, UpsertOutcome shapes -- `crates/core/src/metadata.rs` — similar value-type + port colocation to mirror -- `crates/db/src/metadata_repo.rs` — post-v0.4.1 reference for `&self` + `Mutex` + `BEGIN IMMEDIATE` + `update_thumbnail` trait method pattern. Especially: use `transaction_with_behavior(TransactionBehavior::Immediate)` + `OptionalExtension::optional()` for SELECT-then-INSERT/UPDATE. -- `crates/db/migrations/V003__file_metadata_thumbnails.sql` — additive ALTER pattern -- `crates/cli/src/cmd/metadata.rs` — path-suffix-match hash lookup (reused in `tag add`) -- `crates/cli/src/cmd/ls.rs` — `LsArgs` extension + `print_table_with_metadata` helper pattern -- `crates/cli/src/main.rs` — clap Command enum + dispatch_* helper pattern -- `crates/desktop/src/payloads.rs` — payload pattern (NEW file for v0.5.1: add TagPayload + FileWithTagsPayload alongside existing FileWithMetadataPayload) -- `apps/desktop/src/components/FileGrid.tsx` + `FileTable.tsx` — component pattern -- `apps/desktop/src/App.tsx` — view-mode state; add `selectedTags: Set` - ---- - -## File Structure - -``` -crates/core/src/ -├── tag.rs # NEW — Tag, normalize, MAX_TAG_LEN -├── errors.rs # modify — add InvalidTag variant -└── ports/ - └── tag_repo.rs # NEW — TagRepository trait - -crates/db/src/ -├── tag_repo.rs # NEW — SqliteTagRepository -└── migrations/V005__tags.sql # NEW — tags + file_tags additive - -crates/cli/src/ -├── cmd/tag.rs # NEW — tag add/rm/ls subcommand group -├── cmd/ls.rs # modify — add --tag flag + filter path -└── main.rs # modify — register Tag command - -crates/desktop/src/ -├── commands.rs # modify — add list_tags, attach_tag, detach_tag, list_files_with_tags -├── payloads.rs # modify — add TagPayload + FileWithTagsPayload -└── state.rs # modify — add tag_repo to AppState - -apps/desktop/src/ -├── types.ts # modify — add Tag, FileWithTags -├── api.ts # modify — add listTags/attachTag/detachTag/listFilesWithTags wrappers -├── components/ -│ ├── TagChip.tsx # NEW -│ ├── TagSidebar.tsx # NEW -│ ├── FileTable.tsx # modify — render tags column -│ └── FileGrid.tsx # modify — render tags under tile -├── App.tsx # modify — selectedTags state + sidebar + filter -└── __tests__/ - ├── TagChip.test.tsx # NEW - └── TagSidebar.test.tsx # NEW - -CHANGELOG.md # v0.5.0, v0.5.1 entries -Cargo.toml # workspace version + workspace deps if any -apps/desktop/package.json # version bump -``` - ---- - -## Release 1 — v0.5.0 — backend - -### Task 1: `feat(core): Tag + TagRepository port + InvalidTag error` - -**Files:** -- Create: `crates/core/src/tag.rs` -- Create: `crates/core/src/ports/tag_repo.rs` -- Modify: `crates/core/src/lib.rs`, `crates/core/src/ports/mod.rs`, `crates/core/src/errors.rs` - -Steps: - -- [ ] **Step 1: Add `CoreError::InvalidTag(String)`** - -In `crates/core/src/errors.rs`, extend the enum alongside existing `InvalidPath`/`InvalidHash`: - -```rust -#[derive(Debug, thiserror::Error)] -pub enum CoreError { - // … existing variants … - #[error("invalid tag: {0}")] - InvalidTag(String), -} -``` - -- [ ] **Step 2: Author `crates/core/src/tag.rs`** - -```rust -//! Tag value type + name normalization. - -use serde::{Deserialize, Serialize}; -use unicode_normalization::UnicodeNormalization; -use uuid::Uuid; - -use crate::CoreError; - -/// Maximum tag length after normalization, in Unicode scalar values. -pub const MAX_TAG_LEN: usize = 64; - -/// A tag — user-assignable label on content. -/// -/// WHY only `id`/`name`/`first_seen` exposed: CRDT bookkeeping -/// (`updated_at`/`device_id`/`deleted_at`) is repo-internal; UI and -/// future FFI/HTTP adapters don't need it. Keeps the domain type -/// minimal and stable. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Tag { - /// UUIDv7 primary key. - pub id: Uuid, - /// NFC-normalized lowercase, trimmed. - pub name: String, - /// ISO 8601 UTC timestamp of first sighting. - pub first_seen: String, -} - -/// Normalize a user-supplied tag name to its canonical form. -/// -/// Steps: trim whitespace, NFC-normalize, lowercase. Reject empty -/// or longer than [`MAX_TAG_LEN`] chars post-normalization. -/// -/// WHY core-level (not repo-level): shared by CLI + future HTTP API -/// + FFI adapters. Mirrors `MediaPath::new`'s design. -/// -/// # Errors -/// [`CoreError::InvalidTag`] on empty, whitespace-only, or overlong -/// input. -pub fn normalize(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CoreError::InvalidTag("tag name is empty".into())); - } - let normalized: String = trimmed.nfc().flat_map(char::to_lowercase).collect(); - if normalized.chars().count() > MAX_TAG_LEN { - return Err(CoreError::InvalidTag(format!( - "tag exceeds {MAX_TAG_LEN} chars: {raw:?}" - ))); - } - Ok(normalized) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_lowercases_and_nfc() { - assert_eq!(normalize("Vacation").unwrap(), "vacation"); - // Precomposed "café" vs decomposed should collapse. - let decomposed: String = "cafe\u{0301}".nfd().collect(); - assert_eq!(normalize(&decomposed).unwrap(), "café"); - } - - #[test] - fn normalize_trims() { - assert_eq!(normalize(" tag ").unwrap(), "tag"); - } - - #[test] - fn normalize_rejects_empty() { - assert!(matches!(normalize(""), Err(CoreError::InvalidTag(_)))); - assert!(matches!(normalize(" "), Err(CoreError::InvalidTag(_)))); - } - - #[test] - fn normalize_rejects_too_long() { - let too_long = "x".repeat(MAX_TAG_LEN + 1); - assert!(matches!(normalize(&too_long), Err(CoreError::InvalidTag(_)))); - } -} -``` - -- [ ] **Step 3: Author `crates/core/src/ports/tag_repo.rs`** - -Copy `metadata_repo.rs` style verbatim for consistency. Trait signature per the spec — all methods `&self`; `upsert_tag(name, device)`; include `tags_for_hashes(&[BlakeHash]) -> HashMap>`. **Do NOT include `files_with_tag`** (frontend filters client-side). - -- [ ] **Step 4: Register in `lib.rs` / `ports/mod.rs`** - -```rust -// lib.rs -pub mod tag; -pub use tag::{Tag, normalize as normalize_tag, MAX_TAG_LEN}; -``` - -```rust -// ports/mod.rs -pub mod tag_repo; -pub use tag_repo::TagRepository; -``` - -- [ ] **Step 5: Gate + commit** - -```bash -cargo check -p perima-core 2>&1 | tail -5 -cargo test -p perima-core 2>&1 | tail -5 -just ci -git add crates/core/ -git commit -m "feat(core): Tag + TagRepository port + InvalidTag error - -Adds perima-core tag module: Tag value type, normalize() helper -(NFC + lowercase + trim with MAX_TAG_LEN cap), CoreError::InvalidTag -variant. TagRepository trait uses &self with interior mutability, -matching MetadataRepository pattern. - -No files_with_tag method — frontend filters client-side from the -new list_files_with_tags Tauri command. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -git push origin main -``` - ---- - -### Task 2: `feat(db): V005 tags + file_tags migration + SqliteTagRepository` - -**Files:** -- Create: `crates/db/migrations/V005__tags.sql` -- Create: `crates/db/src/tag_repo.rs` -- Modify: `crates/db/src/lib.rs` - -Steps: - -- [ ] **Step 1: Write V005 migration** - -Copy the spec's exact DDL. Verify: `tags` + `file_tags` tables, both with CRDT columns (updated_at + device_id + deleted_at), composite `(blake3_hash, tag_id)` covering index filtered `WHERE deleted_at IS NULL`, reverse `tag_id` index, and `tags.name` partial index `WHERE deleted_at IS NULL`. - -- [ ] **Step 2: TDD — write failing tests for SqliteTagRepository** - -Add to `crates/db/src/tag_repo.rs` tests module. Use `test_db()` helper local pattern: - -```rust -fn test_db() -> (tempfile::TempDir, SqliteTagRepository) { - let td = tempfile::tempdir().expect("tempdir"); - let conn = crate::connection::open_and_migrate(&td.path().join("test.db")).expect("open"); - (td, SqliteTagRepository::new(conn)) -} - -fn device() -> DeviceId { DeviceId::new() } -``` - -Tests to land (names fixed; TDD red-then-green per-test): - -- `upsert_tag_inserts_new` -- `upsert_tag_idempotent_on_repeat` -- `upsert_tag_normalizes_case_and_nfc` -- `attach_inserts_new` -- `attach_idempotent_on_repeat` -- `detach_softdeletes` -- `tags_for_hashes_single_hash` (covers the `tags_for_file` use-case) -- `tags_for_hashes_batch_returns_map` -- `tags_for_hashes_empty_input_shortcircuits` (regression for the SQL `IN ()` parse-error edge case) -- `files_with_tag_returns_hashes` -- `count_files_for_tag_is_o1` -- `delete_preserves_attachments` -- `recreate_after_delete_yields_new_id` -- `find_or_create_tag_concurrent_unique` (Barrier pattern from v0.3.1) - -- [ ] **Step 3: Implement SqliteTagRepository** - -Same patterns as `SqliteMetadataRepository`: - -- `Mutex` with `const fn new(conn: Connection)`. -- All methods `&self`. -- `upsert_tag` calls `perima_core::normalize_tag(name)?` first, then SELECT-by-normalized-name inside `BEGIN IMMEDIATE` tx; UUID::now_v7() for new rows. -- `attach` same BEGIN IMMEDIATE + SELECT-by-`(hash, tag_id, deleted_at IS NULL)` idempotence check. -- `detach` UPDATE to set `deleted_at = `. -- `delete_tag` UPDATE `tags` only; `file_tags` untouched (CRDT preserve). -- `tags_for_hashes(hashes)` — **MUST short-circuit on empty slice** (return `Ok(HashMap::new())` before composing SQL; `IN ()` is a SQLite parse error not a binding error). For non-empty: JOIN `file_tags` + `tags`, `WHERE blake3_hash IN (?, ?, …)` via `rusqlite::params_from_iter`, group into `HashMap>` in Rust. -- `files_with_tag(tag_id)` = SELECT blake3_hash FROM file_tags WHERE tag_id = ?1 AND deleted_at IS NULL. -- `count_files_for_tag(tag_id)` = SELECT COUNT(*) — single-row scalar. -- `list_tags` = plain SELECT sorted by name. - -- [ ] **Step 4: Register + gate + commit** - -```bash -just ci # 39+ → 50ish DB tests -git add crates/db/ -git commit -m "feat(db): V005 tags + SqliteTagRepository - -New tags + file_tags tables (additive; schema freeze holds). Both -content-adjacent (blake3_hash-keyed) — a tag attaches to content, -not to a specific file location. CRDT-compliant: soft deletes, -updated_at + device_id on every mutable row, no UNIQUE on mutable -columns, no FK cascades. Composite (blake3_hash, tag_id) covering -index + reverse tag_id index. - -SqliteTagRepository mirrors SqliteMetadataRepository: &self methods, -Mutex, BEGIN IMMEDIATE around all read-modify-write -sequences. upsert_tag delegates to perima_core::normalize_tag for -name canonicalization. tags_for_hashes batches via params_from_iter -+ HashMap group. - -Tests (11 new): idempotence, NFC + case normalization, attach/ -detach, delete preserves attachments + recreate yields new id -(pinned regression), Barrier-driven concurrent upsert. - -Co-Authored-By: Claude Opus 4.6 (1M context) " -git push origin main -``` - ---- - -### Task 3: `feat(cli): tag subcommand + ls --tag flag` - -**Files:** -- Create: `crates/cli/src/cmd/tag.rs` -- Modify: `crates/cli/src/cmd/ls.rs` -- Modify: `crates/cli/src/cmd/mod.rs`, `main.rs` -- Create: `crates/cli/tests/tag_cli_test.rs` - -Steps: - -- [ ] **Step 1: Define `TagArgs` + subcommands in `cmd/tag.rs`** - -Using clap derive, a `Tag` command with subcommands `Add`, `Rm`, `Ls`: - -```rust -#[derive(clap::Args, Debug)] -pub struct TagArgs { - #[command(subcommand)] - pub action: TagAction, -} - -#[derive(clap::Subcommand, Debug)] -pub enum TagAction { - /// Tag a file with one or more labels. - Add { - path: PathBuf, - // WHY required + 1..: clap's default for Vec is "zero or - // more positionals" — `perima tag add foo.jpg` would silently - // no-op. Force at least one tag argument so the error is a - // clear "missing required tag" message instead of success- - // that-did-nothing. - #[arg(required = true, num_args = 1..)] - tags: Vec, - }, - /// Remove a tag from a file. - Rm { path: PathBuf, tag: String }, - /// List all active tags with attachment counts. - Ls { - #[arg(long)] - json: bool, - }, -} -``` - -Dispatch in `tag::run(args, tag_repo, file_repo)`: - -- `Add`: reuse `cmd::metadata::find_by_suffix` to resolve path → hash; for each tag, `tag_repo.upsert_tag(name, device)?.id` then `tag_repo.attach(hash, tag_id, device)?`. Print `tagged with `. -- `Rm`: find hash; `upsert_tag` to resolve tag id (idempotent, reuses active); `detach(hash, tag_id, device)`. Print `removed from `. -- `Ls`: `tag_repo.list_tags()`; for each tag, `tag_repo.count_files_for_tag(tag_id)`. (Both methods defined in Task 2.) - -- [ ] **Step 2: Extend `LsArgs` with `--tag `** - -```rust -pub struct LsArgs { - // existing - #[arg(long)] - pub tag: Option, -} -``` - -Branch in `ls::run`: if `tag.is_some()`, normalize via `normalize_tag`, look up tag id, call `tag_repo.files_with_tag(tag_id)` returning Vec, then filter the `list_file_locations` output to that set. - -**Add `files_with_tag(tag_id) -> Vec`** to TagRepository trait + impl. (Contra spec's original "no files_with_tag method" — CLI path needs it server-side because it's not trivially post-filterable through the existing ls query. Desktop still uses the merged `list_files_with_tags` + client-side filter.) Document this deviation with a WHY comment in the trait. - -- [ ] **Step 3: Register `Tag` command in main.rs** - -Add `Tag(TagArgs)` variant to `Command`, add `dispatch_tag(...)` helper mirroring the existing pattern. Construct `SqliteTagRepository` the same way `SqliteMetadataRepository` is constructed. - -- [ ] **Step 4: Integration test `tag_cli_test.rs`** - -- Scan tempdir with 2 files. -- Invoke `perima tag add vacation sunset` twice (idempotence). -- Invoke `perima tag rm sunset`. -- Invoke `perima ls --tag vacation`, assert only file1 is in output. -- Invoke `perima tag ls`, assert `vacation` has count=1 and `sunset` exists but count=0. - -- [ ] **Step 5: Gate + commit** - -```bash -just ci -git commit -m "feat(cli): perima tag add/rm/ls + ls --tag filter" -git push origin main -``` - ---- - -### Task 4: `chore(release): v0.5.0` - -- [ ] Bump Cargo.toml 0.4.1 → 0.5.0; workspace dep `perima-media` → 0.5.0. -- [ ] Bump apps/desktop/package.json 0.4.1 → 0.5.0. -- [ ] Append `## [0.5.0]` entry to CHANGELOG — cover core + db + cli feats. -- [ ] `just ci` -- [ ] Commit `chore(release): v0.5.0 — tag backend` + push. -- [ ] Release-plz's `release` job auto-creates tag + GH Release on push. -- [ ] Update GH release title for consistency. - ---- - -## Release 2 — v0.5.1 — desktop UI - -### Task 5: `feat(desktop): Tauri tag commands + payloads + list_files_with_tags` - -**Files:** -- Modify: `crates/desktop/src/commands.rs`, `payloads.rs`, `state.rs`, `lib.rs` -- Modify: `apps/desktop/src/types.ts`, `api.ts` - -Steps: - -- [ ] **Step 1: Extend AppState with `tag_repo: Arc`** - -Same rationale as `metadata_repo` — shared long-lived handle. Document the continued deviation from per-call-open with a WHY comment alongside the existing one. - -- [ ] **Step 2: Add `TagPayload` + `FileWithTagsPayload` to `payloads.rs`** - -`TagPayload` = flat serde mirror of `Tag` (id as String, name, first_seen). -`FileWithTagsPayload` = all fields from `FileWithMetadataPayload` + `tags: Vec`. **Do not extend `FileWithMetadataPayload`** — compose. - -- [ ] **Step 3: Add 4 Tauri commands** - -- `list_tags() -> Vec` — calls `tag_repo.list_tags()` and maps. -- `attach_tag(hash: String, tag_name: String) -> Result<(), String>` — parses hex hash; `tag_repo.upsert_tag(name, device)?.id`; `tag_repo.attach(...)`. -- `detach_tag(hash: String, tag_id: String) -> Result<(), String>` — parses uuid; detach. -- `list_files_with_tags(limit: u32, volume: Option) -> Vec` — calls `metadata_repo.list_with_metadata`, collects hash set, calls `tag_repo.tags_for_hashes`, merges in Rust. - // WHY two queries + merge (not a shared tx): the two-SELECT sequence - // has a race under WAL — a `file_tags` insert between calls could - // produce tags for a hash not in the metadata set (harmless — we - // iterate the metadata list and look up by hash, so extra tags are - // ignored), and a metadata delete between calls leaves a stale tag - // entry in the map (also harmless for the same reason). Transient - // inconsistency is acceptable for UI list refresh. Flagging so - // future readers don't chase a phantom correctness bug. - -Register in `specta_builder.invoke_handler()` via `collect_commands!`. - -- [ ] **Step 4: TS types + api wrappers** - -`apps/desktop/src/types.ts`: -```ts -export interface Tag { id: string; name: string; first_seen: string; } -export interface FileWithTags extends FileWithMetadata { tags: Tag[]; } -``` - -`apps/desktop/src/api.ts`: add `listTags`, `attachTag`, `detachTag`, `listFilesWithTags` following the existing `fromInvoke` pattern. - -- [ ] **Step 5: Smoke test in `crates/desktop/tests/commands_test.rs`** - -Reuse pattern from `list_files_with_metadata_returns_rows`. Add one tag, attach, call `list_files_with_tags`, assert non-empty response with tag populated. - -- [ ] **Step 6: Gate + commit + push** - ---- - -### Task 6: `feat(desktop): TagChip + TagSidebar + App wiring` - -**Files:** -- Create: `apps/desktop/src/components/TagChip.tsx`, `TagSidebar.tsx` -- Modify: `FileTable.tsx`, `FileGrid.tsx`, `App.tsx`, setup.ts if needed -- Create: `apps/desktop/src/__tests__/TagChip.test.tsx`, `TagSidebar.test.tsx` - -Steps: - -- [ ] **Step 1: `TagChip.tsx`** - -```tsx -interface TagChipProps { - tag: Tag; - onRemove?: () => void; -} -``` - -Compute color class: `blake3` isn't available in TS — use a simple JS alternative: `Array.from(new TextEncoder().encode(tag.name)).reduce((a,b)=>(a+b)%256,0) % 12` as `colorIndex`. Then map to a Tailwind palette. Good enough for procedural coloring; no crypto-strength needed here. - -Chip markup: small rounded pill, bg-{color}-700, text-white, optional × button. - -- [ ] **Step 2: `TagSidebar.tsx`** - -Props: `{ tags: Tag[]; selected: Set; onSelect: (tagId: string | null) => void }`. - -Renders "All" + each tag as a clickable row with count. Highlights `selected`. - -- [ ] **Step 3: Extend `FileTable` + `FileGrid`** with a tag row per file. Cap at 3 visible + "+N" overflow badge. - -- [ ] **Step 4: `App.tsx` wiring** - -- Change `useEffect` mount call from `listFilesWithMetadata` to `listFilesWithTags`. -- Add `tags: Tag[]` state (from `listTags()` on mount — **second invoke call, needs its own mock in App.test.tsx**). -- Add `selectedTagId: string | null` state (modelled internally as `Set` per spec for future multi-select; v0.5.1 UI shows single). -- If `selectedTagId` is set, filter the `files` array to those whose `tags` contains that id. -- Render `` as left column when tags exist. - -- [ ] **Step 4b: Update `App.test.tsx`** - -Existing tests filter invoke calls by `cmd === "list_files_with_metadata"` — this must change to `"list_files_with_tags"` to keep the debounce / error-surfacing tests passing. Also mock `list_tags` to resolve `[]` on mount so the second invoke in the mount effect doesn't reject (mirror the v0.4.1 pattern when we switched from `list_files` to `list_files_with_metadata`). - -```tsx -// In each existing test's mock setup: -(invoke as Mock).mockImplementation(async (cmd: string) => { - if (cmd === "list_tags") return []; - if (cmd === "list_files_with_tags") return []; - return []; -}); -// Filter assertions: `cmd === "list_files_with_tags"`. -``` - -- [ ] **Step 5: vitest** - -- `TagChip.test.tsx`: renders name, click × calls onRemove, no × prop → no button. -- `TagSidebar.test.tsx`: renders 3 tags + All, click tag calls onSelect with id, click All calls onSelect(null), selected tag has highlight class. - -- [ ] **Step 6: Gate + commit + push** - ---- - -### Task 7: `chore(release): v0.5.1` - -Same pattern as Task 4. - ---- - -## Exit criteria - -- [ ] V005 migration runs cleanly on v0.4.1 DB; no data loss. -- [ ] `just ci` green across all commits. -- [ ] v0.5.0 tagged (release-plz auto); backend fully exercised via `perima tag` + `perima ls --tag`. -- [ ] v0.5.1 tagged (release-plz auto); grid/table show chips; sidebar filters. -- [ ] No regressions in v0.4.x tests. -- [ ] Phase 5a task #29 closed. - -## Risks - -- **`files_with_tag` method added despite spec saying no** — clarified in Task 3. CLI needs it server-side for `ls --tag`; desktop still doesn't. Noting explicitly so reviewer doesn't flag as drift. -- **`count_files_for_tag` repo method** — added to support `perima tag ls` counts. Could be avoided by computing count in Rust after `list_tags + tags_for_hashes` but that's O(N tags × M files); dedicated SELECT COUNT is O(1) per tag. -- **`tags_for_hashes` SQL** — `IN (?, ?, …)` with `params_from_iter` — make sure binding works for empty slice (edge case). If rusqlite errors on empty params, short-circuit `if hashes.is_empty() { return Ok(HashMap::new()) }`. -- **Hash-to-color in TS** — `blake3` isn't easily reachable; simple byte-sum is "good enough". Collisions are just duplicate tag colors, non-critical. - -## Self-review - -**Spec coverage:** -- Tag + normalize + error → Task 1 ✓ -- V004 + SqliteTagRepository → Task 2 ✓ -- CLI tag commands + ls --tag → Task 3 ✓ -- v0.5.0 release → Task 4 ✓ -- Tauri commands + payloads → Task 5 ✓ -- TagChip + sidebar + wiring → Task 6 ✓ -- v0.5.1 release → Task 7 ✓ - -**Drift from reviewer resolutions:** -- Task 3 adds `files_with_tag` + `count_files_for_tag` repo methods (spec said no). Justified for CLI path efficiency; desktop still uses client-side filter per spec. - -**Placeholder scan:** Task 2 Step 3's impl is prose + bullet points, not verbatim code — deliberately. The pattern mirrors `SqliteMetadataRepository` which is already in the repo; implementer reads that file + applies. Concrete snippets only where novel (e.g., `params_from_iter` for `IN (...)` binding isn't in phase 4). - -**Type consistency:** `TagRepository` trait signature and the concrete impl's method set agree. `FileWithTagsPayload` composes `FileWithMetadataPayload` (not extends — compose is cleaner per reviewer Q5 resolution). diff --git a/docs/superpowers/plans/2026-04-16-v0.6-search.md b/docs/superpowers/plans/2026-04-16-v0.6-search.md deleted file mode 100644 index 2fdc725..0000000 --- a/docs/superpowers/plans/2026-04-16-v0.6-search.md +++ /dev/null @@ -1,474 +0,0 @@ -# v0.6.x — FTS5 Search Implementation Plan - -**Spec:** `docs/superpowers/specs/2026-04-16-v0.6-search-design.md` - -**Goal:** v0.6.0 = FTS5 backend + CLI search. v0.6.1 = Tauri command + React SearchBar. - -**Env prefix (required before any cargo/just):** -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH="/tmp/tauri-pc:/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig" -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - -**Real-code anchors:** -- `crates/db/migrations/V005__tags.sql` — migration pattern -- `crates/db/src/tag_repo.rs` — `SqliteTagRepository` pattern (Mutex, BEGIN IMMEDIATE, params_from_iter) -- `crates/core/src/ports/tag_repo.rs` — trait port pattern -- `crates/cli/src/cmd/tag.rs` — CLI subcommand pattern -- `crates/desktop/src/commands.rs` — Tauri command pattern -- `apps/desktop/src/components/TagSidebar.tsx` — sidebar component pattern -- `apps/desktop/src/__tests__/TagSidebar.test.tsx` — vitest component test pattern - ---- - -## Release 1 — v0.6.0 — backend - -### Task 1: `feat(db): V006 migration + FTS5 virtual table + sync triggers + rebuild` - -**Files:** -- Create: `crates/db/migrations/V006__search.sql` -- Create: `crates/db/src/search_repo.rs` -- Modify: `crates/db/src/lib.rs` - -**Steps:** - -- [ ] **Step 1: Write V006 migration** - -Copy the exact DDL from the spec (`docs/superpowers/specs/2026-04-16-v0.6-search-design.md` §2): - -- `search_index` — FTS5 virtual table, `content=""`, 6 columns: - `filename, relative_path, mime_type, camera_model, captured_at, tags`. -- `search_rowid_map (rowid INTEGER PK, blake3_hash, volume_id, relative_path)` — side table. -- 4 triggers: `search_after_metadata_insert`, `search_after_metadata_update`, - `search_after_file_tags_insert`, `search_after_file_tags_update`. - -Copy the SQL verbatim from the spec. Do NOT paraphrase — the trigger bodies use -SQLite-specific syntax (`REVERSE()`, `INSTR()`) that must be exact. - -- [ ] **Step 2: TDD — failing tests for `SqliteSearchRepository`** - -Create `crates/db/src/search_repo.rs` with a `#[cfg(test)]` module. Use -`test_db()` returning `(tempfile::TempDir, SqliteSearchRepository)` — identical -pattern to `tag_repo.rs`. - -Tests (write these FIRST; they should fail until Step 3): - -- `search_empty_index_returns_empty` — search on fresh DB returns `[]`. -- `search_finds_by_filename` — insert a `files` row + `file_metadata` row, - call `rebuild()`, search the filename, assert one hit with correct hash. -- `search_finds_by_mime_type` — insert `image/jpeg` metadata, rebuild, query - `"image/jpeg"`, assert hit. -- `search_finds_by_tag` — upsert a tag, attach it to a file, rebuild, query - the tag name, assert hit. -- `search_rank_orders_better_match_first` — two files, one has the query term - in both filename AND tags (should rank higher), assert order. -- `rebuild_is_idempotent` — rebuild twice, assert same hit count. -- `trigger_sync_on_metadata_insert` — insert metadata WITHOUT rebuild, then - search, assert hit (trigger should have synced). -- `trigger_sync_on_tag_attach` — attach tag without rebuild, search tag name, - assert hit. -- `search_limit_is_respected` — insert 5 files, search `*`, limit 2, assert 2. - -- [ ] **Step 3: Implement `SqliteSearchRepository`** - -```rust -pub struct SqliteSearchRepository { - conn: Mutex, -} - -impl SqliteSearchRepository { - pub const fn new(conn: Connection) -> Self { ... } -} - -impl SearchRepository for SqliteSearchRepository { - fn search(&self, query: &str, limit: u32) -> Result, CoreError> { ... } - fn rebuild(&self) -> Result<(), CoreError> { ... } -} -``` - -`search`: -```sql -SELECT srm.blake3_hash, srm.volume_id, srm.relative_path, rank -FROM search_index -JOIN search_rowid_map srm ON srm.rowid = search_index.rowid -WHERE search_index MATCH ?1 -ORDER BY rank -LIMIT ?2 -``` -- Lock mutex, prepare statement, collect rows into `Vec`. -- Propagate `rusqlite::Error` → `CoreError::Db(err.to_string())`. - -`rebuild`: Execute the rebuild SQL from spec §2 in a `BEGIN IMMEDIATE` transaction. - -- [ ] **Step 4: Register + gate + commit** - -In `crates/db/src/lib.rs`: -```rust -pub mod search_repo; -pub use search_repo::SqliteSearchRepository; -``` - -```bash -just ci -git add crates/db/ -git commit -m "feat(db): V006 FTS5 search_index + SqliteSearchRepository - -Adds search_index (FTS5 contentless virtual table) + search_rowid_map -side table for rowid→(hash,volume,path) lookup. Four sync triggers -keep the index current on file_metadata + file_tags changes without a -separate background job. SqliteSearchRepository::rebuild() does a full -drop-and-rebuild inside BEGIN IMMEDIATE for migration/recovery use. - -Tests (9): empty index, filename/mime/tag match, rank order, idempotent -rebuild, trigger sync on metadata insert + tag attach, limit respected." -git push origin main -``` - ---- - -### Task 2: `feat(core): SearchRepository port + SearchHit type` - -**Files:** -- Create: `crates/core/src/search.rs` -- Create: `crates/core/src/ports/search_repo.rs` -- Modify: `crates/core/src/lib.rs`, `crates/core/src/ports/mod.rs` - -**Steps:** - -- [ ] **Step 1: Author `crates/core/src/search.rs`** - -```rust -//! Search hit value type. -use serde::{Deserialize, Serialize}; - -/// A ranked search result referencing a file location. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SearchHit { - pub blake3_hash: String, - pub volume_id: String, - pub relative_path: String, - /// BM25 rank (lower = better, SQLite convention). - pub rank: f64, -} -``` - -- [ ] **Step 2: Author `crates/core/src/ports/search_repo.rs`** - -```rust -use crate::{CoreError, SearchHit}; - -pub trait SearchRepository: Send + Sync { - fn search(&self, query: &str, limit: u32) -> Result, CoreError>; - fn rebuild(&self) -> Result<(), CoreError>; -} -``` - -- [ ] **Step 3: Register + add `CoreError::Db` variant if missing** - -Check `crates/core/src/errors.rs`. Add `Db(String)` alongside `InvalidTag` if it doesn't exist, for adapters to wrap `rusqlite::Error`. - -Register in `lib.rs`: -```rust -pub mod search; -pub use search::SearchHit; -// ports/mod.rs -pub mod search_repo; -pub use search_repo::SearchRepository; -``` - -- [ ] **Step 4: Update `SqliteSearchRepository` in `crates/db` to use the core trait** - -In `crates/db/src/search_repo.rs`, change the impl block from using a local trait to `impl perima_core::SearchRepository for SqliteSearchRepository`. - -- [ ] **Step 5: Gate + commit** - -```bash -just ci -git add crates/core/ crates/db/src/search_repo.rs -git commit -m "feat(core): SearchRepository port + SearchHit type - -Adds perima-core search module: SearchHit value type (hash + volume + -path + bm25 rank) and SearchRepository trait (search + rebuild). CoreError::Db -variant added for adapter error wrapping. SqliteSearchRepository now -implements the core trait." -git push origin main -``` - ---- - -### Task 3: `feat(cli): perima search subcommand` - -**Files:** -- Create: `crates/cli/src/cmd/search.rs` -- Modify: `crates/cli/src/cmd/mod.rs`, `crates/cli/src/main.rs` -- Create: `crates/cli/tests/search_cli_test.rs` - -**Steps:** - -- [ ] **Step 1: Define `SearchArgs` in `cmd/search.rs`** - -```rust -#[derive(clap::Args, Debug)] -pub struct SearchArgs { - /// FTS5 match expression (e.g. "vacation", "image/*", "camera:canon*"). - #[arg(required_unless_present = "rebuild")] - pub query: Option, - - #[arg(long, default_value = "50")] - pub limit: u32, - - #[arg(long)] - pub json: bool, - - /// Rebuild the FTS5 index and exit. - #[arg(long)] - pub rebuild: bool, -} -``` - -`search::run(args, search_repo)`: -- If `rebuild`: call `search_repo.rebuild()`, print row count (count from - `search_rowid_map`), exit. -- Validate query non-empty. -- Call `search_repo.search(query, limit)`. -- If `json`: print JSON array. -- Else: print table with HASH(8), PATH, RANK(4dp) columns. - -- [ ] **Step 2: Register in main.rs** - -Add `Search(SearchArgs)` to the `Command` enum. Construct -`SqliteSearchRepository::new(conn)` in the dispatch path. - -- [ ] **Step 3: Integration test `search_cli_test.rs`** - -- Scan tempdir with 3 files (use fixture from `manifest_created.rs` pattern). -- Invoke `perima search --rebuild`, assert exit 0. -- Invoke `perima search alpha`, assert output contains `alpha.txt`. -- Invoke `perima search --json beta`, assert output is valid JSON array with - one element containing `"beta.txt"`. -- Invoke `perima search nonexistent_xyz`, assert output is empty (no rows). - -- [ ] **Step 4: Gate + commit** - -```bash -just ci -git add crates/cli/ -git commit -m "feat(cli): perima search + --rebuild flag - -CLI full-text search over the FTS5 index. Supports FTS5 match syntax -(prefix *, phrase, column: filters). --rebuild wipes and repopulates -the index from current DB state. --json emits machine-readable output. -Integration test covers rebuild + exact match + JSON mode + no-results." -git push origin main -``` - ---- - -### Task 4: `chore(release): v0.6.0` - -- [ ] Bump `Cargo.toml` workspace version `0.5.1 → 0.6.0`; workspace dep - `perima-media` → `0.6.0`. -- [ ] Bump `apps/desktop/package.json` version `0.5.1 → 0.6.0`. -- [ ] Append `## [0.6.0] — ` to `CHANGELOG.md` after `## [Unreleased]`. - Contents: ### Added — FTS5 search_index + sync triggers, SearchRepository - port + SearchHit, CLI `perima search`. - Update `[Unreleased]` compare link → `v0.6.0...HEAD`. - Add `[0.6.0]: https://github.com/utof/perima/releases/tag/v0.6.0` link. -- [ ] `just ci` — all green. -- [ ] Commit `chore(release): v0.6.0 — FTS5 search backend` + push. - ---- - -## Release 2 — v0.6.1 — desktop UI - -### Task 5: `feat(desktop): search Tauri command + SearchHitPayload + TS wrapper` - -**Files:** -- Modify: `crates/desktop/src/commands.rs`, `payloads.rs`, `state.rs`, `lib.rs` -- Modify: `apps/desktop/src/types.ts`, `api.ts` - -**Steps:** - -- [ ] **Step 1: Add `search_repo: Arc` to `AppState`** - -Same pattern as `tag_repo`. Document with WHY comment (shared long-lived -handle; same rationale as metadata_repo). - -- [ ] **Step 2: `SearchHitPayload` in `payloads.rs`** - -```rust -#[derive(Serialize, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct SearchHitPayload { - pub hash: String, - pub volume_id: String, - pub relative_path: String, - pub rank: f64, -} -``` - -- [ ] **Step 3: `search` Tauri command** - -```rust -#[tauri::command] -#[specta::specta] -async fn search( - query: String, - limit: Option, - state: State<'_, AppState>, -) -> Result, String> { - if query.trim().is_empty() { - return Ok(vec![]); - } - let limit = limit.unwrap_or(100).min(500); - state.search_repo - .search(&query, limit) - .map(|hits| hits.into_iter().map(SearchHitPayload::from).collect()) - .map_err(|e| e.to_string()) -} -``` - -Register in `collect_commands!`. - -- [ ] **Step 4: TS types + api wrapper** - -`types.ts`: -```ts -export interface SearchHit { - hash: string; - volumeId: string; - relativePath: string; - rank: number; -} -``` - -`api.ts`: -```ts -export const searchFiles = (query: string, limit = 100) => - fromInvoke('search', { query, limit }); -``` - -- [ ] **Step 5: Smoke test in `crates/desktop/tests/commands_test.rs`** - -Insert a file + metadata, call `rebuild`, invoke `search`, assert non-empty. - -- [ ] **Step 6: Gate + commit + push** - -```bash -just ci -git add crates/desktop/ apps/desktop/src/types.ts apps/desktop/src/api.ts -git commit -m "feat(desktop): search Tauri command + SearchHitPayload + TS wrapper" -git push origin main -``` - ---- - -### Task 6: `feat(desktop): SearchBar component + App.tsx integration` - -**Files:** -- Create: `apps/desktop/src/components/SearchBar.tsx` -- Modify: `apps/desktop/src/App.tsx` -- Create: `apps/desktop/src/__tests__/SearchBar.test.tsx` -- Modify: `apps/desktop/src/__tests__/App.test.tsx` - -**Steps:** - -- [ ] **Step 1: `SearchBar.tsx`** - -```tsx -interface SearchBarProps { - onResults: (hits: SearchHit[]) => void; - onClear: () => void; -} -``` - -- Controlled input with a clear (×) button. -- Debounce 300 ms; skip if `query.trim().length < 2`. -- On debounced change: call `api.searchFiles(query, 100).match(onResults, setError)`. -- On clear: reset input, call `onClear()`. -- Loading indicator while search is in-flight. -- WHY 2-char minimum: single-char FTS5 queries on large indexes are expensive - and produce noisy results. - -- [ ] **Step 2: App.tsx integration** - -- Add `searchHits: SearchHit[] | null` state (null = no active search). -- Render `` in the header next to the view-mode toggle. -- `visibleFiles` computation (existing): if `searchHits !== null`, filter - `files` to those whose `hash` appears in the search hit set. Apply after tag - filter so the two filters compose. -- `onClear`: set `searchHits = null` (restores full unfiltered list). -- WHY composition order (tag filter first, then search filter): tag filter is - always client-side from an already-fetched list; search filter comes from - the FTS5 index. Composing them: `visibleFiles = files.filter(f => - tagPredicate(f) && searchPredicate(f))` where `searchPredicate` is a - `Set` of hashes from `searchHits`. - -- [ ] **Step 3: `SearchBar.test.tsx`** - -- Renders input and no clear button when empty. -- Typing fewer than 2 chars does NOT call invoke. -- Typing 3+ chars after 300 ms debounce calls `invoke('search', ...)` once. -- Clear button resets input and calls `onClear`. -- Test uses `vi.useFakeTimers()` for debounce. - -- [ ] **Step 4: Update `App.test.tsx`** - -Add `search` mock to `invoke` implementation (returns `[]`). Existing -debounce/watcher tests should not need other changes. - -- [ ] **Step 5: Gate + commit + push** - -```bash -just ci -git add apps/desktop/ -git commit -m "feat(desktop): SearchBar component + App.tsx search integration - -Debounced search bar (300ms, min 2 chars) composes with existing tag -filter — both predicates apply to the visible file list. Clear resets -to unfiltered view. Tests cover debounce, min-char gate, clear." -git push origin main -``` - ---- - -### Task 7: `chore(release): v0.6.1` - -- [ ] Bump versions `0.6.0 → 0.6.1` in `Cargo.toml` + `package.json`. -- [ ] Append `## [0.6.1] — ` to `CHANGELOG.md`. - Contents: ### Added — `search` Tauri command + `SearchHitPayload` + TS - `searchFiles` wrapper, `SearchBar` component with debounce, App.tsx - search+tag filter composition. - Update compare links. -- [ ] `just ci` — all green. -- [ ] Commit `chore(release): v0.6.1 — desktop search UI` + push. - ---- - -## Exit Criteria - -- [ ] `just ci` green on all commits. -- [ ] FTS5 index stays in sync with DB via triggers (tested). -- [ ] `perima search` returns ranked hits matching FTS5 syntax. -- [ ] Desktop SearchBar composable with tag sidebar (both filters active simultaneously). -- [ ] v0.6.0 tagged (release-plz auto); v0.6.1 tagged. -- [ ] No regressions in v0.5.x tests. - -## Risks - -- **Contentless FTS5 + triggers**: contentless mode means `UPDATE` on - `search_index` must be a delete+insert pair (no true UPDATE). The triggers - implement this correctly but are verbose. If trigger maintenance becomes a - burden, switch to content-table mode (FTS5 stores data itself) at the cost - of storage. -- **Filename extraction in SQL**: `SUBSTR` + `INSTR(REVERSE(...))` is a - SQLite-specific idiom. Test that it handles files without a `/` (root-level - files) and files with multiple `/`s. -- **FTS5 query injection**: user-supplied `query` is passed directly to FTS5 - MATCH. FTS5 has its own parse errors (malformed queries) — wrap in a - `rusqlite::Error` handler and surface as `CoreError::Db`. -- **`search_after_file_tags_update` trigger**: fires on every `UPDATE` to - `file_tags`, including the `deleted_at` soft-delete write. This is correct - behavior but adds one FTS5 write per detach operation. diff --git a/docs/superpowers/plans/2026-04-17-v0.6.2-live-faceted-search.md b/docs/superpowers/plans/2026-04-17-v0.6.2-live-faceted-search.md deleted file mode 100644 index 26c87bc..0000000 --- a/docs/superpowers/plans/2026-04-17-v0.6.2-live-faceted-search.md +++ /dev/null @@ -1,1575 +0,0 @@ -# v0.6.2 Live Faceted Search Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace v0.6.1's drill-down search (click hit → show one file, clear tag filter) with live inline narrowing (typing narrows the list in place, facet-panel sidebar shows tags in current results, AND-composes with tag filter). Closes issue #25. - -**Architecture:** Pure-function library (`lib/search.ts`) owns query sanitisation, facet reduction, and composition. SearchBar is a thin input that lifts `(query, hits)` via callback. App.tsx composes state → derived visible list → children. No backend changes; all composition client-side on already-loaded `FileWithTags[]`. - -**Tech Stack:** React 19 + TypeScript 5 + Vite 6 + Tailwind 4 (existing); Vitest + jsdom + @testing-library/react for tests; neverthrow for the `ResultAsync` pattern in `api.ts`. - -**Spec:** `docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md` (commit `92f520f`). - ---- - -## Binding process rules (non-negotiable) - -- **ONE plan task = ONE commit.** Never bundle Task N and Task N+1 into one commit, even if small. -- **Commit scope matches majority of changed files.** For this plan, every task is in `apps/desktop/**` → `feat(desktop)`, `fix(desktop)`, or `test(desktop)` as appropriate. -- **TDD per task:** write failing test → run + confirm fail → implement minimally → run + confirm pass → commit. -- **Pre-commit hook runs `just ci`.** Do not bypass with `--no-verify`. If it fails, fix the root cause. -- **After every `feat(desktop)` commit, dispatch TWO review subagents** (spec-compliance + code-quality) per the tightened rules in `.claude/commands/autonomous-continue.md` (Rule 3). Fix Important+ findings in follow-up `fix(desktop): ...` commits before moving to the next task. -- **No `Co-Authored-By:` trailers.** Ever. -- **Zero review-fix commits across the phase = red flag** requiring explicit justification in the `chore(release)` body. -- **`headless-tested: yes|no` line required** in every `feat(desktop)` / `fix(desktop)` commit body. - -## Env setup (required before any `cargo`, `just`, or `bun` invocation) - -```bash -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH" -export PKG_CONFIG_PATH=/tmp/tauri-pc -export LIBRARY_PATH=/tmp/tauri-libs:/usr/lib/x86_64-linux-gnu -export RUSTFLAGS="-L/tmp/tauri-libs -L/usr/lib/x86_64-linux-gnu" -``` - -If Tauri library symlinks are missing (`/tmp/tauri-libs/*.so`), see `scripts/pre-commit` + the first "Errors and fixes" in prior session notes — recreate via `ln -sf` loop over the 17 expected libs. - ---- - -## File Structure - -``` -apps/desktop/src/ -├── lib/ -│ ├── search.ts # NEW (Task 1-3): pure functions — buildFtsQuery, computeFacets, composeVisible, sortByRank -│ └── __tests__/ -│ └── search.test.ts # NEW (Task 1-3): unit tests for lib/search.ts -├── components/ -│ ├── SearchBar.tsx # MODIFY (Task 4): remove dropdown, onQueryChange callback, limit 500 -│ └── TagSidebar.tsx # MODIFY (Task 5): add mode prop, facet-aware rendering -├── App.tsx # MODIFY (Task 6): remove searchHash/handleSearchResult, add searchQuery/searchHits/hitRanks -├── __tests__/ -│ ├── SearchBar.test.tsx # MODIFY (Task 4): drop dropdown tests, add onQueryChange tests -│ ├── TagSidebar.test.tsx # MODIFY (Task 5): add facets-mode tests -│ ├── App.test.tsx # MODIFY (Task 6): update mock expectations for new handler -│ └── App.compose.test.tsx # NEW (Task 7): composition snapshot test — the bug-pin for #25 -``` - -No Rust / backend changes. No new dependencies. No migrations. - ---- - -## Task 1: `lib/search.ts` — `buildFtsQuery` sanitiser (TDD) - -**Files:** -- Create: `apps/desktop/src/lib/search.ts` -- Create: `apps/desktop/src/lib/__tests__/search.test.ts` - -- [ ] **Step 1.1: Write the failing tests** - -Create `apps/desktop/src/lib/__tests__/search.test.ts` with the following content: - -```typescript -import { describe, it, expect } from "vitest"; -import { buildFtsQuery } from "../search"; - -describe("buildFtsQuery", () => { - it("quotes plain tokens with implicit AND", () => { - expect(buildFtsQuery("sunset photos")).toBe('"sunset" "photos"'); - }); - - it("passes explicit phrase queries verbatim", () => { - expect(buildFtsQuery('"blue ridge"')).toBe('"blue ridge"'); - }); - - it("wraps prefix queries with quoted-token + asterisk", () => { - expect(buildFtsQuery("sunse*")).toBe('"sunse"*'); - }); - - it("handles apostrophes inside quoted tokens", () => { - expect(buildFtsQuery("it's fine")).toBe('"it\'s" "fine"'); - }); - - it("handles slashes and dots inside quoted tokens", () => { - expect(buildFtsQuery("a/b.jpg")).toBe('"a/b.jpg"'); - }); - - it("returns empty string on whitespace-only input", () => { - expect(buildFtsQuery(" ")).toBe(""); - expect(buildFtsQuery("")).toBe(""); - }); - - it("strips parens and leading dashes before tokenising", () => { - // (foo OR bar) → parens stripped → tokens foo, OR, bar - expect(buildFtsQuery("(foo OR bar)")).toBe('"foo" "OR" "bar"'); - }); - - it("strips bare unpaired double-quote", () => { - expect(buildFtsQuery('"')).toBe(""); - }); - - it("strips leading dash on a token (FTS5 negation hazard)", () => { - expect(buildFtsQuery("-foo")).toBe('"foo"'); - }); -}); -``` - -- [ ] **Step 1.2: Run tests to confirm they fail** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: FAIL — module `../search` not found (file doesn't exist yet). - -- [ ] **Step 1.3: Write the minimal implementation** - -Create `apps/desktop/src/lib/search.ts`: - -```typescript -/** - * Search-related pure functions. No React imports; test directly. - * - * WHY a separate module: composing search hits + tag filters + BM25 - * rank sort is the exact logic that broke in v0.6.1 (issue #25). - * Pulling it out of App.tsx makes it unit-testable and pins the - * regression invariant in `App.compose.test.tsx`. - */ - -/** - * Sanitise raw user input into an FTS5-safe query string. - * - * Supports three FTS5 patterns: - * - Phrase query: input wrapped in `"..."` passes through. - * - Prefix query: input ending in `*` becomes `"tokens"*`. - * - Implicit AND: plain tokens get individually quoted + joined with - * spaces (FTS5 implicit-AND semantics). - * - * Pre-strips parse-unsafe characters (bare parens, leading dash, bare - * unpaired quote) to avoid FTS5 parse errors on normal-ish typing. - * The user loses access to FTS5's `AND / OR / NEAR / NOT` keywords as - * a side effect — by design; post-v1 query DSL picks that up. - */ -export function buildFtsQuery(raw: string): string { - const trimmed = raw.trim(); - if (trimmed === "") return ""; - - // Pass-through: explicit phrase query already wrapped. - // WHY this check is first: otherwise the strip pass below would kill - // the outer quotes and re-quote the inner tokens, losing the phrase. - if ( - trimmed.length >= 2 && - trimmed.startsWith('"') && - trimmed.endsWith('"') - ) { - return trimmed; - } - - // Prefix query: `foo*` at end of input → take everything up to `*` - // as tokens, re-emit last token as `"last"*`, keep earlier tokens - // as plain implicit-AND. - if (trimmed.endsWith("*") && trimmed.length >= 2) { - const withoutStar = trimmed.slice(0, -1); - const sanitized = stripUnsafe(withoutStar); - const tokens = sanitized.split(/\s+/).filter((t) => t !== ""); - if (tokens.length === 0) return ""; - const last = tokens.pop()!; - const prefix = tokens.map((t) => `"${t}"`).join(" "); - const suffix = `"${last}"*`; - return prefix === "" ? suffix : `${prefix} ${suffix}`; - } - - // Plain tokens: strip unsafe, split, quote each. - const sanitized = stripUnsafe(trimmed); - const tokens = sanitized.split(/\s+/).filter((t) => t !== ""); - return tokens.map((t) => `"${t}"`).join(" "); -} - -/** Strip FTS5-unsafe characters before tokenisation. */ -function stripUnsafe(s: string): string { - return s - .replace(/[()"]/g, "") // bare parens and bare quotes - .replace(/(^|\s)-/g, "$1"); // leading dash on a token -} -``` - -- [ ] **Step 1.4: Run tests to confirm pass** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: PASS — 9 tests green. - -- [ ] **Step 1.5: Commit** - -```bash -git add apps/desktop/src/lib/search.ts apps/desktop/src/lib/__tests__/search.test.ts -git commit -m "feat(desktop): buildFtsQuery sanitiser for FTS5 search input (#32) - -Phrase / prefix / implicit-AND subset of FTS5 grammar, safe against -parens, bare quotes, and leading-dash negation. 9 unit tests pin the -behaviour. - -headless-tested: yes (vitest units, no runtime)" -``` - ---- - -## Task 2: `lib/search.ts` — `computeFacets` reducer (TDD) - -**Files:** -- Modify: `apps/desktop/src/lib/search.ts` (append) -- Modify: `apps/desktop/src/lib/__tests__/search.test.ts` (append) - -- [ ] **Step 2.1: Write the failing tests** - -Append to `apps/desktop/src/lib/__tests__/search.test.ts`: - -```typescript -import { computeFacets } from "../search"; -import type { FileWithTags } from "../../types"; - -function file(hash: string, tagIds: string[]): FileWithTags { - return { - hash, - size: 0, - volume_id: "vol", - relative_path: `${hash}.jpg`, - status: "active", - first_seen: "2026-01-01T00:00:00Z", - width: null, - height: null, - duration_ms: null, - captured_at: null, - camera_make: null, - camera_model: null, - codec: null, - bitrate_bps: null, - mime_type: null, - thumbnail_path: null, - thumbnail_status: null, - tags: tagIds.map((id) => ({ id, name: `tag-${id}`, first_seen: "2026-01-01T00:00:00Z" })), - }; -} - -describe("computeFacets", () => { - it("returns empty object on empty file list", () => { - expect(computeFacets([])).toEqual({}); - }); - - it("returns empty object when no file has tags", () => { - expect(computeFacets([file("a", []), file("b", [])])).toEqual({}); - }); - - it("counts each tag occurrence across files", () => { - const files = [ - file("a", ["vacation"]), - file("b", ["vacation", "sunset"]), - file("c", ["sunset"]), - ]; - expect(computeFacets(files)).toEqual({ - vacation: 2, - sunset: 2, - }); - }); - - it("handles many files with same tag", () => { - const files = Array.from({ length: 5 }, (_, i) => - file(`h${i}`, ["vacation"]), - ); - expect(computeFacets(files)).toEqual({ vacation: 5 }); - }); -}); -``` - -- [ ] **Step 2.2: Run tests to confirm they fail** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: FAIL — `computeFacets` is not a function / not exported. - -- [ ] **Step 2.3: Write the minimal implementation** - -Append to `apps/desktop/src/lib/search.ts`: - -```typescript -import type { FileWithTags } from "../types"; - -/** - * Tally tag occurrences across a file set. - * - * WHY client-side: for v0.6.2 the list is capped at 100 rows - * (`listFilesWithTags(100)`) so counts are cheap and reactive. - * Facet counts reflect the visible result set only; full-corpus - * counts are a post-v1 optimization (spec Non-goals). - */ -export function computeFacets(files: FileWithTags[]): Record { - const counts: Record = {}; - for (const f of files) { - for (const t of f.tags) { - counts[t.id] = (counts[t.id] ?? 0) + 1; - } - } - return counts; -} -``` - -- [ ] **Step 2.4: Run tests to confirm pass** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: PASS — 13 tests green (9 from Task 1 + 4 new). - -- [ ] **Step 2.5: Commit** - -```bash -git add apps/desktop/src/lib/search.ts apps/desktop/src/lib/__tests__/search.test.ts -git commit -m "feat(desktop): computeFacets tag-count reducer (#32) - -Client-side reducer over FileWithTags[]; tallies tag id → occurrence -count. Drives the facet-panel sidebar when search narrows the list. - -headless-tested: yes (vitest units)" -``` - ---- - -## Task 3: `lib/search.ts` — `composeVisible` + `sortByRank` (TDD) - -**Files:** -- Modify: `apps/desktop/src/lib/search.ts` (append) -- Modify: `apps/desktop/src/lib/__tests__/search.test.ts` (append) - -- [ ] **Step 3.1: Write the failing tests** - -Append to `apps/desktop/src/lib/__tests__/search.test.ts`: - -```typescript -import { composeVisible, sortByRank } from "../search"; - -describe("composeVisible", () => { - const files = [ - file("a", ["vacation"]), - file("b", ["vacation", "sunset"]), - file("c", ["sunset"]), - file("d", []), - ]; - - it("returns all files when no filters", () => { - expect(composeVisible(files, null, null)).toEqual(files); - }); - - it("filters by tag id", () => { - expect(composeVisible(files, "vacation", null).map((f) => f.hash)).toEqual([ - "a", - "b", - ]); - }); - - it("filters by search hit set", () => { - const hits = new Set(["b", "d"]); - expect(composeVisible(files, null, hits).map((f) => f.hash)).toEqual([ - "b", - "d", - ]); - }); - - it("intersects tag and search filters (THE #25 regression)", () => { - const hits = new Set(["a", "b", "c"]); - expect(composeVisible(files, "vacation", hits).map((f) => f.hash)).toEqual([ - "a", - "b", - ]); - }); - - it("returns empty list on empty search hit set", () => { - expect(composeVisible(files, null, new Set())).toEqual([]); - }); - - it("returns empty list on unknown tag id", () => { - expect(composeVisible(files, "nonexistent", null)).toEqual([]); - }); -}); - -describe("sortByRank", () => { - it("orders files by rank ascending (lower = better)", () => { - const visible = [file("a", []), file("b", []), file("c", [])]; - const ranks = new Map([ - ["a", -1.0], - ["b", -2.5], - ["c", -1.5], - ]); - expect(sortByRank(visible, ranks).map((f) => f.hash)).toEqual([ - "b", - "c", - "a", - ]); - }); - - it("appends files missing from rank map at the end", () => { - const visible = [file("a", []), file("b", []), file("c", [])]; - const ranks = new Map([["a", -1.0]]); - const result = sortByRank(visible, ranks).map((f) => f.hash); - // "a" first (ranked); "b"/"c" at end in arbitrary order - expect(result[0]).toBe("a"); - expect(result.slice(1).sort()).toEqual(["b", "c"]); - }); - - it("returns empty list unchanged", () => { - expect(sortByRank([], new Map())).toEqual([]); - }); -}); -``` - -- [ ] **Step 3.2: Run tests to confirm they fail** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: FAIL — `composeVisible` and `sortByRank` not exported. - -- [ ] **Step 3.3: Write the minimal implementation** - -Append to `apps/desktop/src/lib/search.ts`: - -```typescript -/** - * Compose the visible file set from base list + optional tag + optional search hits. - * - * - `selectedTagId === null`: no tag filter. - * - `searchHits === null`: no search active (returns tag-filtered list). - * - `searchHits instanceof Set` with 0 entries: search active, zero matches - * (returns []). Distinct from null — this is the spec's `searchActive` - * invariant. - * - * THE core invariant of #25: when both filters are active, the result - * is the INTERSECTION, not one overriding the other. - */ -export function composeVisible( - files: FileWithTags[], - selectedTagId: string | null, - searchHits: Set | null, -): FileWithTags[] { - return files.filter((f) => { - if (selectedTagId !== null && !f.tags.some((t) => t.id === selectedTagId)) { - return false; - } - if (searchHits !== null && !searchHits.has(f.hash)) { - return false; - } - return true; - }); -} - -/** - * Sort files by BM25 rank (ascending — lower = better per FTS5 convention). - * - * Files whose hash is not in `hitRanks` are appended to the end. In practice - * `composeVisible` ensures every visible file has a hit (when searchHits is - * non-null), so the fallback only fires on defensive misuse. - */ -export function sortByRank( - files: FileWithTags[], - hitRanks: Map, -): FileWithTags[] { - const ranked: Array<{ file: FileWithTags; rank: number }> = []; - const unranked: FileWithTags[] = []; - for (const f of files) { - const r = hitRanks.get(f.hash); - if (r === undefined) unranked.push(f); - else ranked.push({ file: f, rank: r }); - } - ranked.sort((a, b) => a.rank - b.rank); - return [...ranked.map((x) => x.file), ...unranked]; -} -``` - -- [ ] **Step 3.4: Run tests to confirm pass** - -```bash -cd apps/desktop && bun run test -- search.test -``` - -Expected: PASS — 22 tests green (13 prior + 9 new). - -- [ ] **Step 3.5: Commit** - -```bash -git add apps/desktop/src/lib/search.ts apps/desktop/src/lib/__tests__/search.test.ts -git commit -m "feat(desktop): composeVisible + sortByRank (#32) - -composeVisible is the #25 invariant: tag-filter ∩ search-hits instead -of one overriding the other. sortByRank applies FTS5 BM25 ordering -when a search is active. Both pure; 9 new unit tests. - -headless-tested: yes (vitest units)" -``` - ---- - -## Task 4: Modify `SearchBar.tsx` — remove dropdown, add `onQueryChange` - -**Files:** -- Modify: `apps/desktop/src/components/SearchBar.tsx` -- Modify: `apps/desktop/src/__tests__/SearchBar.test.tsx` - -- [ ] **Step 4.1: Update SearchBar tests first (TDD)** - -Overwrite `apps/desktop/src/__tests__/SearchBar.test.tsx` with the updated version. Key changes from the current file: drop tests that assert dropdown rendering or click-a-hit; add tests for `onQueryChange` callback. Keep: debounce, MIN_QUERY_LEN=2 boundary, clear-button. - -Replace the file contents: - -```typescript -import { render, screen, fireEvent, act } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { okAsync, errAsync } from "neverthrow"; -import SearchBar from "../components/SearchBar"; -import type { SearchHit } from "../types"; - -vi.mock("../api", () => ({ - search: vi.fn(), -})); - -import * as api from "../api"; - -const mockSearch = vi.mocked(api.search); - -const hit: SearchHit = { - blake3_hash: "abcdef1234567890", - volume_id: "vol-1", - relative_path: "photos/sunset.jpg", - rank: -1.5, -}; - -beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -async function advanceAndFlush(ms: number) { - await act(async () => { - vi.advanceTimersByTime(ms); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("SearchBar", () => { - it("renders the search input", () => { - render(); - expect(screen.getByRole("searchbox")).toBeInTheDocument(); - }); - - it("does not fire search for single-character query", async () => { - mockSearch.mockReturnValue(okAsync([hit])); - const onChange = vi.fn(); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "a" }, - }); - await advanceAndFlush(300); - - // The purpose of this test is to confirm no search fires. - // Whether onQueryChange fires (""/null) on the empty→"a" transition - // is covered by clear-signal tests below; here we only pin that - // `api.search` stays untouched for single-char input. - expect(mockSearch).not.toHaveBeenCalled(); - }); - - it("fires search for two-character query at limit 500", async () => { - mockSearch.mockReturnValue(okAsync([hit])); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "ab" }, - }); - await advanceAndFlush(300); - - // buildFtsQuery("ab") → '"ab"' - expect(mockSearch).toHaveBeenCalledWith('"ab"', 500); - }); - - it("fires onQueryChange with (raw, hits) after debounce", async () => { - mockSearch.mockReturnValue(okAsync([hit])); - const onChange = vi.fn(); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "sunset" }, - }); - await advanceAndFlush(300); - - expect(onChange).toHaveBeenCalledWith("sunset", [hit]); - }); - - it("fires onQueryChange(\"\", null) when cleared via ✕", async () => { - mockSearch.mockReturnValue(okAsync([hit])); - const onChange = vi.fn(); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "sunset" }, - }); - await advanceAndFlush(300); - - onChange.mockClear(); - await act(async () => { - fireEvent.click(screen.getByLabelText("Clear search")); - }); - - expect(onChange).toHaveBeenCalledWith("", null); - expect( - (screen.getByRole("searchbox") as HTMLInputElement).value, - ).toBe(""); - }); - - it("fires onQueryChange(raw, []) when search returns zero hits", async () => { - mockSearch.mockReturnValue(okAsync([])); - const onChange = vi.fn(); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "xyzzy" }, - }); - await advanceAndFlush(300); - - expect(onChange).toHaveBeenCalledWith("xyzzy", []); - }); - - it("swallows backend errors and fires onQueryChange(raw, [])", async () => { - mockSearch.mockReturnValue(errAsync("FTS5 parse error")); - const onChange = vi.fn(); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "bad" }, - }); - await advanceAndFlush(300); - - // Non-fatal: show empty results rather than a red banner. - expect(onChange).toHaveBeenCalledWith("bad", []); - }); - - it("does not render a dropdown listbox", async () => { - mockSearch.mockReturnValue(okAsync([hit])); - render(); - - fireEvent.change(screen.getByRole("searchbox"), { - target: { value: "sunset" }, - }); - await advanceAndFlush(300); - - // No listbox — list re-sort happens in App, not here. - expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 4.2: Run tests to confirm they fail** - -```bash -cd apps/desktop && bun run test -- SearchBar.test -``` - -Expected: FAIL — old SearchBar still exports `onResultClick`, not `onQueryChange`; dropdown still rendered. - -- [ ] **Step 4.3: Rewrite SearchBar implementation** - -Overwrite `apps/desktop/src/components/SearchBar.tsx` with: - -```typescript -import { useEffect, useRef, useState } from "react"; -import * as api from "../api"; -import { buildFtsQuery } from "../lib/search"; -import type { SearchHit } from "../types"; - -/** Milliseconds to wait after the user stops typing before firing a search. */ -const DEBOUNCE_MS = 300; - -/** - * Minimum query length before firing a search. - * - * WHY 2: single-char FTS5 queries on a large corpus are expensive and - * produce noisy high-recall results. Two chars is the smallest window - * that meaningfully narrows the index while staying responsive for - * short tag names like "UI" or "JP". - */ -const MIN_QUERY_LEN = 2; - -/** - * Upper bound for a single search call. - * - * WHY 500: the v0.6.2 design re-sorts the visible list by BM25 rank - * while a search is active. The list itself is capped at 100 rows via - * `listFilesWithTags(100)`, so 500 is ≥ 5× headroom — enough to cover - * the visible set even when many files outside the visible set also - * match (they're filtered out by `composeVisible`). The Tauri `search` - * command also clamps to 500 server-side (SEARCH_LIMIT_MAX in - * crates/desktop/src/commands.rs). - */ -const SEARCH_LIMIT = 500; - -interface SearchBarProps { - /** - * Fires whenever the debounced query resolves (with hits) or clears. - * - * Three cases for the two arguments: - * 1. `(raw, hits)` — user typed ≥ MIN_QUERY_LEN; `hits` may be `[]` if - * the query matched nothing. - * 2. `("", null)` — user cleared input (via ✕ or deleting chars) or - * typed less than MIN_QUERY_LEN. App should reset `searchHits` to - * `null` (distinct from `new Set()` — the latter means "searched, - * zero results"). - * 3. Same as #2 on backend error (swallowed; non-fatal). - */ - onQueryChange: (query: string, hits: SearchHit[] | null) => void; -} - -/** - * Debounced FTS5 search input. - * - * WHY self-contained sanitiser + IPC call: keeps the input component - * deciding *when* to query (debounce, min-length guard) while the - * *what* (buildFtsQuery) lives in the shared lib/search module. The - * parent App.tsx only needs to know about the resolved (raw, hits) - * pair — not the FTS5 grammar. - */ -export default function SearchBar({ onQueryChange }: SearchBarProps) { - const [query, setQuery] = useState(""); - const timerRef = useRef | null>(null); - // Track whether the last fire was "cleared" to avoid refiring on - // an already-cleared state when the user backspaces past MIN_QUERY_LEN. - const clearedRef = useRef(true); - - useEffect(() => { - if (timerRef.current) clearTimeout(timerRef.current); - const trimmed = query.trim(); - - if (trimmed.length < MIN_QUERY_LEN) { - // Covers: empty input, 1-char input, whitespace-only input. - // Fire clear exactly once per transition into the cleared state. - if (!clearedRef.current) { - clearedRef.current = true; - onQueryChange("", null); - } - return; - } - - timerRef.current = setTimeout(() => { - const ftsQuery = buildFtsQuery(trimmed); - if (ftsQuery === "") { - // Sanitiser returned nothing (all chars were unsafe). Treat as cleared. - clearedRef.current = true; - onQueryChange("", null); - return; - } - api.search(ftsQuery, SEARCH_LIMIT).match( - (hits) => { - clearedRef.current = false; - onQueryChange(trimmed, hits); - }, - () => { - // WHY swallow: FTS5 parse errors on edge-case input - // (unbalanced quotes after sanitiser, weird unicode). Showing - // an empty result list is honest; a red banner would flash on - // every keystroke that happens to produce transient bad input. - clearedRef.current = false; - onQueryChange(trimmed, []); - }, - ); - }, DEBOUNCE_MS); - - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [query, onQueryChange]); - - function handleClear() { - setQuery(""); - // The effect above will fire onQueryChange("", null) on the next render. - } - - return ( -
-
- 🔍 - setQuery(e.target.value)} - className="flex-1 bg-transparent px-2 py-1.5 text-sm text-gray-100 placeholder-gray-400 outline-none" - /> - {query && ( - - )} -
-
- ); -} -``` - -- [ ] **Step 4.4: Run tests to confirm pass** - -```bash -cd apps/desktop && bun run test -- SearchBar.test -``` - -Expected: PASS — 8 tests green. - -- [ ] **Step 4.5: Commit** - -```bash -git add apps/desktop/src/components/SearchBar.tsx apps/desktop/src/__tests__/SearchBar.test.tsx -git commit -m "feat(desktop): SearchBar drops dropdown, lifts (query, hits) via onQueryChange (#32) - -Removes local hits/open/searching state, the listbox dropdown render, -the outside-click effect, and the onResultClick prop. Calls -api.search(buildFtsQuery(raw), 500) internally on debounced input; fires -onQueryChange(raw, hits) on resolution, onQueryChange(\"\", null) on -clear or below-minimum input. - -8 tests cover: MIN_QUERY_LEN boundary, limit=500, (raw, hits) fire, -clear-transition fire, zero-hit fire, error swallow, no-listbox-rendered. - -headless-tested: yes (vitest component tests)" -``` - -- [ ] **Step 4.6: Two-stage review (spec + code-quality)** - -Per binding Rule 3, dispatch two reviewer subagents before moving to Task 5. Each should verify against `docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md` sections "SearchBar changes" + "Testing — SearchBar.test.tsx". Apply any Important+ findings as a follow-up `fix(desktop): ...` commit, then re-review until clean. - ---- - -## Task 5: Modify `TagSidebar.tsx` — add `mode` prop - -**Files:** -- Modify: `apps/desktop/src/components/TagSidebar.tsx` -- Modify: `apps/desktop/src/__tests__/TagSidebar.test.tsx` - -- [ ] **Step 5.1: Update TagSidebar tests first (TDD)** - -Append to `apps/desktop/src/__tests__/TagSidebar.test.tsx` (keep existing tests; add new ones): - -```typescript -describe("TagSidebar facets mode", () => { - const tags = [ - { id: "t1", name: "vacation", first_seen: "2026-01-01T00:00:00Z" }, - { id: "t2", name: "sunset", first_seen: "2026-01-01T00:00:00Z" }, - { id: "t3", name: "beach", first_seen: "2026-01-01T00:00:00Z" }, - ]; - - it("hides tags with 0 counts when mode=facets", () => { - render( - , - ); - expect(screen.getByText("vacation")).toBeInTheDocument(); - expect(screen.getByText("sunset")).toBeInTheDocument(); - // t3 has no count → hidden in facets mode. - expect(screen.queryByText("beach")).not.toBeInTheDocument(); - }); - - it("shows empty-state row when all counts are 0", () => { - render( - , - ); - expect(screen.getByText("No tags in current results")).toBeInTheDocument(); - }); - - it("All row count is totalCount in facets mode (= sum of counts)", () => { - render( - , - ); - // The "All" row should show count 4 (= sum of visible). - const allRow = screen.getByRole("button", { name: /^All$/i }); - expect(allRow.textContent).toContain("4"); - }); - - it("shows all tags in mode=all regardless of counts", () => { - render( - , - ); - expect(screen.getByText("vacation")).toBeInTheDocument(); - expect(screen.getByText("sunset")).toBeInTheDocument(); - expect(screen.getByText("beach")).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 5.2: Run tests to confirm they fail** - -```bash -cd apps/desktop && bun run test -- TagSidebar.test -``` - -Expected: FAIL — `mode` prop not accepted; facets-mode behaviour not implemented. - -- [ ] **Step 5.3: Update TagSidebar implementation** - -Read current `apps/desktop/src/components/TagSidebar.tsx` first to understand the existing structure; then modify. The expected end state: - -```typescript -import type { Tag } from "../types"; - -interface TagSidebarProps { - /** Full tag list (all known tags). */ - tags: Tag[]; - /** Tag id → file count within the current visible set. */ - counts: Record; - /** Total visible file count (displayed on the "All" row). */ - totalCount: number; - selectedTagId: string | null; - onSelect: (tagId: string | null) => void; - /** - * Rendering mode (optional; defaults to "all" so existing callers - * that don't pass this prop continue to work): - * - "all": show every tag in `tags` (no search active). - * - "facets": show only tags with counts > 0 (search active; the - * sidebar becomes a facet panel over the current results). - */ - mode?: "all" | "facets"; -} - -/** - * Left-column filter: "All" + per-tag rows with attachment counts and - * aria-pressed toggle state. Single-select for v0.5.x; multi-select - * tracked as post-v1 per issue #32. - */ -export default function TagSidebar({ - tags, - counts, - totalCount, - selectedTagId, - onSelect, - mode = "all", -}: TagSidebarProps) { - const visibleTags = - mode === "facets" - ? tags.filter((t) => (counts[t.id] ?? 0) > 0) - : tags; - - return ( - - ); -} - -function SidebarRow({ - label, - count, - active, - onClick, -}: { - label: string; - count?: number; - active: boolean; - onClick: () => void; -}) { - const base = - "flex items-center justify-between px-2 py-1.5 text-sm rounded cursor-pointer transition-colors"; - const activeCls = "bg-blue-600 text-white"; - const inactiveCls = "text-gray-300 hover:bg-gray-700"; - const countCls = active ? "text-blue-200" : "text-gray-400"; - return ( - - ); -} -``` - -- [ ] **Step 5.4: Run tests to confirm pass** - -```bash -cd apps/desktop && bun run test -- TagSidebar.test -``` - -Expected: PASS — all existing tests still green + 4 new facets-mode tests green. - -- [ ] **Step 5.5: Commit** - -```bash -git add apps/desktop/src/components/TagSidebar.tsx apps/desktop/src/__tests__/TagSidebar.test.tsx -git commit -m "feat(desktop): TagSidebar mode=facets hides 0-count tags (#32) - -New required \`mode: \"all\" | \"facets\"\` prop. In facets mode the -sidebar filters to tags with count > 0 and shows an empty-state row -when the narrowed result set has no tags. All row count reflects -totalCount (= sum of visible counts in facets mode). - -4 new tests cover facet filtering, empty state, All-row count, -and all-mode passthrough. - -headless-tested: yes (vitest component tests)" -``` - -- [ ] **Step 5.6: Two-stage review (spec + code-quality)** - -Same as Task 4 Step 6 — dispatch reviewers; apply findings as `fix(desktop): ...` commits. - ---- - -## Task 6: Modify `App.tsx` — replace `searchHash` with composed state - -**Files:** -- Modify: `apps/desktop/src/App.tsx` -- Modify: `apps/desktop/src/__tests__/App.test.tsx` (verify no regression) - -- [ ] **Step 6.1: Run existing App.test.tsx first to capture baseline** - -```bash -cd apps/desktop && bun run test -- App.test -``` - -Expected: PASS (2 tests — file-event debounce + watcher banner). Nothing to change in `App.test.tsx` per spec (`cd55627` already updated the mock command names). - -- [ ] **Step 6.2: Rewrite App.tsx composition block** - -Replace the current state + compose block. Key changes: - -- Remove `searchHash: string | null` state. -- Remove `handleSearchResult` function. -- Remove the `✕ search` badge JSX from the header. -- Add `searchQuery: string` + `searchHits: Set | null` + `hitRanks: Map` state. -- Add `handleSearchChange` handler. -- Import `composeVisible`, `sortByRank`, `computeFacets` from `./lib/search`. -- Compute derived `visibleFiles`, `facetCounts`, `sidebarMode`, `sidebarTotalCount`. -- Update `` prop: `onQueryChange={handleSearchChange}`. -- Update `` props: add `mode={sidebarMode}`, `counts={facetCounts}`, `totalCount={sidebarTotalCount}`. - -Show the exact intended replacement for the state declaration block (after loading, error, scanResult, scanning, viewMode, files, tags, selectedTagId, watcherError — all KEEP as-is): - -```typescript -// REPLACE: `const [searchHash, setSearchHash] = useState(null);` -// WITH: -const [searchQuery, setSearchQuery] = useState(""); -const [searchHits, setSearchHits] = useState | null>(null); -const [hitRanks, setHitRanks] = useState>(new Map()); -``` - -Replace the `handleSearchResult` function entirely with: - -```typescript -/** - * Receives debounced (query, hits) from SearchBar. Lifts into App state - * so the visible-file composition can re-run. - * - * WHY Set + Map instead of the raw SearchHit[]: composeVisible does an - * O(1) membership check per file; sortByRank does an O(1) rank lookup. - * Storing the raw array would mean O(n*m) filtering per render. - */ -function handleSearchChange(query: string, hits: SearchHit[] | null) { - setSearchQuery(query); - if (hits === null) { - setSearchHits(null); - setHitRanks(new Map()); - } else { - setSearchHits(new Set(hits.map((h) => h.blake3_hash))); - setHitRanks(new Map(hits.map((h) => [h.blake3_hash, h.rank]))); - } -} -``` - -Replace the `visibleFiles` computation block: - -```typescript -// REPLACE: the existing searchHash-based filter with: -const searchActive = searchHits !== null; -const baseVisible = composeVisible(files, selectedTagId, searchHits); -const visibleFiles = searchActive ? sortByRank(baseVisible, hitRanks) : baseVisible; -const facetCounts = computeFacets(visibleFiles); -const sidebarMode: "all" | "facets" = searchActive ? "facets" : "all"; -const sidebarTotalCount = searchActive ? visibleFiles.length : files.length; -``` - -In the JSX header block, replace the `` + `✕ search` badge block with: - -```tsx - -``` - -In the JSX main block, replace the `` props to include `mode` + updated counts: - -```tsx - -``` - -Add imports at the top: - -```typescript -import { composeVisible, computeFacets, sortByRank } from "./lib/search"; -``` - -(Do NOT re-import `SearchHit` — it is already imported in the current -`App.tsx` line 11 as part of the existing `import type { -FileWithTags, ScanResult, SearchHit, Tag } from "./types";` line. -Adding a duplicate import would fail ESLint's `no-duplicate-imports` -rule and break `just ci`.) - -**Also REMOVE the existing `counts` derivation block** (App.tsx -lines 162-167 in current state — the block starting with -`// Compute per-tag file counts from the full unfiltered list.`). -The new `facetCounts` replaces it; leaving the old block in produces -an unused-variable lint error under `eslint -D warnings`, failing CI. - -- [ ] **Step 6.3: Run App.test.tsx — confirm existing tests still pass** - -```bash -cd apps/desktop && bun run test -- App.test -``` - -Expected: PASS — both existing tests still green (file-event debounce + watcher banner). SearchBar mock returns `okAsync([])` by default; App.tsx should handle empty hits fine. - -- [ ] **Step 6.4: Run full vitest suite** - -```bash -cd apps/desktop && bun run test -``` - -Expected: PASS — everything green (22 tests in search.test, 8 in SearchBar, 9 in TagSidebar, 2 in App, plus all unchanged suites). - -- [ ] **Step 6.5: Commit** - -```bash -git add apps/desktop/src/App.tsx -git commit -m "feat(desktop): App.tsx composes search + tag filter (closes #25) - -Removes searchHash / handleSearchResult / ✕-badge drill-down from -v0.6.1. New state: searchQuery + searchHits (Set) + hitRanks (Map). -New handleSearchChange lifts (query, hits) from SearchBar. visibleFiles -composes via files.filter(tagOk && hitOk), then sortByRank when search -active. TagSidebar receives mode=facets while search active. - -This is the bug-fix of #25: tag selection is NO LONGER cleared when a -search hit arrives. Filter composition is now the documented invariant -(covered by App.compose.test.tsx in Task 7). - -headless-tested: yes (vitest — full 45-test suite green)" -``` - -- [ ] **Step 6.6: Two-stage review** - -Same as before. Especially important here: the spec-reviewer subagent -should verify against the spec's "State (owned by App.tsx)" block -line-by-line (including `hitRanks` per review finding I-1). - ---- - -## Task 7: NEW — `App.compose.test.tsx` composition snapshot test - -**Files:** -- Create: `apps/desktop/src/__tests__/App.compose.test.tsx` - -This test file pins the exact #25 invariant (tag ∩ search) so any future App.tsx rewrite can't silently regress. - -- [ ] **Step 7.1: Write the snapshot test** - -Create `apps/desktop/src/__tests__/App.compose.test.tsx`: - -```typescript -import { describe, it, expect } from "vitest"; -import { composeVisible, computeFacets, sortByRank } from "../lib/search"; -import type { FileWithTags } from "../types"; - -function file(hash: string, tagIds: string[]): FileWithTags { - return { - hash, - size: 0, - volume_id: "vol", - relative_path: `${hash}.jpg`, - status: "active", - first_seen: "2026-01-01T00:00:00Z", - width: null, - height: null, - duration_ms: null, - captured_at: null, - camera_make: null, - camera_model: null, - codec: null, - bitrate_bps: null, - mime_type: null, - thumbnail_path: null, - thumbnail_status: null, - tags: tagIds.map((id) => ({ id, name: `tag-${id}`, first_seen: "2026-01-01T00:00:00Z" })), - }; -} - -/** - * App.tsx composition invariant. - * - * These snapshots pin the #25 regression. Any change to App.tsx's - * visibleFiles / facetCounts derivation should be reflected here - * deliberately — if a test breaks, the composition semantics changed - * and the failure is the intended signal. - */ -describe("App composition invariant", () => { - const files = [ - file("a", ["vacation"]), - file("b", ["vacation", "sunset"]), - file("c", ["sunset"]), - file("d", []), - ]; - - function compose( - tagId: string | null, - hits: Set | null, - ranks: Map = new Map(), - ) { - const searchActive = hits !== null; - const base = composeVisible(files, tagId, hits); - const visible = searchActive ? sortByRank(base, ranks) : base; - const counts = computeFacets(visible); - const mode: "all" | "facets" = searchActive ? "facets" : "all"; - const total = searchActive ? visible.length : files.length; - return { visible: visible.map((f) => f.hash), counts, mode, total }; - } - - it("case 1: no search, no tag → all files, mode=all", () => { - expect(compose(null, null)).toEqual({ - visible: ["a", "b", "c", "d"], - counts: { vacation: 2, sunset: 2 }, - mode: "all", - total: 4, - }); - }); - - it("case 2: tag filter only → tag-narrowed, mode=all", () => { - expect(compose("vacation", null)).toEqual({ - visible: ["a", "b"], - counts: { vacation: 2, sunset: 1 }, - mode: "all", - total: 4, // full set count — no search active - }); - }); - - it("case 3: search only → hit-narrowed, sorted by rank, mode=facets", () => { - const hits = new Set(["a", "b", "c"]); - const ranks = new Map([ - ["a", -1.0], - ["b", -2.5], // best - ["c", -1.5], - ]); - expect(compose(null, hits, ranks)).toEqual({ - visible: ["b", "c", "a"], - counts: { vacation: 2, sunset: 2 }, - mode: "facets", - total: 3, - }); - }); - - it("case 4: search + tag → INTERSECTED, sorted by rank, mode=facets (the #25 pin)", () => { - const hits = new Set(["a", "b", "c"]); - const ranks = new Map([ - ["a", -1.0], - ["b", -2.5], - ["c", -1.5], - ]); - expect(compose("vacation", hits, ranks)).toEqual({ - visible: ["b", "a"], - counts: { vacation: 2, sunset: 1 }, - mode: "facets", - total: 2, - }); - }); - - it("case 5: search active but zero hits → empty list, mode=facets", () => { - expect(compose(null, new Set())).toEqual({ - visible: [], - counts: {}, - mode: "facets", - total: 0, - }); - }); -}); -``` - -- [ ] **Step 7.2: Run the test** - -```bash -cd apps/desktop && bun run test -- App.compose -``` - -Expected: PASS — 5 tests green. (This test only uses pure functions from `lib/search.ts` — no React rendering.) - -- [ ] **Step 7.3: Run full vitest suite one more time** - -```bash -cd apps/desktop && bun run test -``` - -Expected: PASS — all suites green. - -- [ ] **Step 7.4: Commit** - -```bash -git add apps/desktop/src/__tests__/App.compose.test.tsx -git commit -m "test(desktop): App composition invariant snapshot (closes #25) - -Pins the tag ∩ search intersection semantics with 5 cases: -1. No search, no tag → all files, mode=all -2. Tag only → tag-narrowed -3. Search only → hit-narrowed + rank-sorted, mode=facets -4. Search + tag → INTERSECTED + rank-sorted (the #25 regression pin) -5. Search active with 0 hits → empty list, mode=facets (distinct - from case 1 since hits=Set(), not null) - -Any future App.tsx rewrite that changes composition semantics must -update this file deliberately — a test break is the intended signal." -``` - ---- - -## Task 8: Release `chore(release): v0.6.2` - -**Files:** -- Modify: `Cargo.toml` -- Modify: `apps/desktop/package.json` -- Modify: `CHANGELOG.md` - -- [ ] **Step 8.1: Bump workspace version** - -Edit `Cargo.toml` — find `[workspace.package]` block, change: - -```toml -version = "0.6.1" -``` - -to: - -```toml -version = "0.6.2" -``` - -AND find the `[workspace.dependencies]` section and change `perima-media = { path = "crates/media", version = "0.6.1" }` to `version = "0.6.2"`. - -- [ ] **Step 8.2: Bump desktop package version** - -Edit `apps/desktop/package.json`: - -```json -"version": "0.6.2", -``` - -(replacing `"0.6.1"`). - -- [ ] **Step 8.3: Write CHANGELOG.md entry** - -Add under `## [Unreleased]` (immediately above the `## [0.6.1]` entry): - -```markdown -## [0.6.2] — 2026-04-17 - -### Fixed - -- **Search + tag filter composition (#25).** In v0.6.1, clicking a - search result silently cleared the tag-sidebar selection, making - tag-scoped search impossible. v0.6.2 makes the two filters AND-compose: - the visible list is always the intersection of the active tag (if - any) and the active search match set (if any). Pinned by - `App.compose.test.tsx` snapshot. - -### Changed - -- **Search UX: live inline narrowing + facet sidebar** (#32). - - Typing in the search bar now narrows the file list in place; the - dropdown preview is removed. - - While a search is active the list re-sorts by FTS5 BM25 rank - (lower = better match); the prior sort restores when the search - clears. - - The tag sidebar transforms into a facet panel when a search is - active: only tags present in the current visible result set are - shown, with live counts that reflect the narrowed list. - - Clicking a sidebar tag AND-composes with the active search - (instead of replacing it). Clicking again toggles the tag filter - off. -- **SearchBar input limit raised from 50 → 500 results** per query to - feed the in-list re-sort (Tauri command clamps at 500 server-side). -- **Query sanitiser** added: plain-text input is auto-quoted; explicit - phrase queries (`"blue ridge"`) and prefix queries (`sunse*`) are - honoured; unsafe chars (bare parens, leading dashes, unpaired - quotes) are stripped. Advanced FTS5 operators (NEAR, column - filters, AND/OR keywords) are not exposed — tracked for post-v1 - query DSL. - -### Notes - -- **Facet counts reflect visible result set only** (capped at 100 - rows via `listFilesWithTags(100)`). Full-corpus counts are a - post-v1 optimization. -- **Escape key** only clears the search input while focused. Full - Escape stack (pop filters, pop detail view, etc.) is scope of #28 - (keyboard registry) and #27 (three-pane layout). -- **Stale search hits after watcher refresh:** when the file watcher - fires a list refresh while a search is active, `searchHits` is not - automatically re-queried. User can retype to re-fire. Post-v1 fix: - auto-re-search on watcher-driven refresh. -``` - -And update the `[Unreleased]` compare link at the bottom of `CHANGELOG.md`: - -```markdown -[Unreleased]: https://github.com/utof/perima/compare/v0.6.2...HEAD -[0.6.2]: https://github.com/utof/perima/releases/tag/v0.6.2 -``` - -(Insert the `[0.6.2]` link in version order with the other tag links at the bottom.) - -- [ ] **Step 8.4: Run `just ci` to confirm everything green** - -```bash -just ci -``` - -Expected: ALL GREEN — fmt, clippy, cargo tests, doc, desktop crate tests, vitest, eslint. No failures. - -- [ ] **Step 8.5: Commit the release** - -```bash -git add Cargo.toml apps/desktop/package.json CHANGELOG.md -git commit -m "chore(release): v0.6.2 — live faceted search (closes #25) - -v0.6.2 ships the live faceted search UX: search narrows the list in -place, sidebar transforms into a facet panel, and tag + search -compose via AND instead of one clobbering the other. - -Closes #25. Builds on issue #32. - -Review-fix commits: this release contains 6 feat/test commits across -3 TDD'd pure-function steps (Tasks 1-3) + 3 component integration -steps (Tasks 4-6) + 1 composition snapshot pin (Task 7). Two-stage -reviews were dispatched after each feat commit per the binding rules. - -headless-tested: yes (45+ vitest tests; no Tauri runtime)" -git push origin main -``` - -- [ ] **Step 8.6: Confirm release-plz auto-tags** - -Release-plz should detect the `chore(release): v0.6.2` commit on push -and auto-create the `v0.6.2` tag + GitHub Release. Verify: - -```bash -sleep 60 && git fetch origin --tags && git tag --sort=-version:refname | head -5 -``` - -Expected: `v0.6.2` appears at the top. If not after 2 min, check GH -Actions for the release-plz workflow status. - -- [ ] **Step 8.7: Close issue #25 + link #32** - -```bash -gh issue close 25 --comment "Closed by v0.6.2 — live faceted search. See release notes + commit 92f520f (spec) + the feat/fix commits in the v0.6.1..v0.6.2 range." -gh issue comment 32 --body "v0.6.2 ships the core of this issue — live inline narrowing + facet panel + AND composition with tag filter. Remaining sub-items (multi-tag OR, full-corpus facet counts, advanced query DSL) remain tracked here as post-v1." -``` - ---- - -## Exit criteria - -- [ ] v0.6.2 tag live on GitHub -- [ ] Issue #25 closed -- [ ] `just ci` green on `main` at `v0.6.2` -- [ ] 45+ vitest tests passing across `search.test`, `SearchBar.test`, `TagSidebar.test`, `App.test`, `App.compose.test` -- [ ] Each `feat(desktop)` / `fix(desktop)` commit body includes `headless-tested: yes` -- [ ] At least one `fix(desktop): ...` review-fix commit (addressing Task 4 / 5 / 6 review findings) — if none were found, release body must explicitly say "reviewers approved as-is" per Rule 3 - ---- - -## Handoff - -Plan saved to `docs/superpowers/plans/2026-04-17-v0.6.2-live-faceted-search.md`. - -**Dispatch subagent reviewer next** (NOT self-review per user directive — memory: `feedback_never_self_review.md`). Reviewer should verify: -1. Every spec section maps to at least one task. -2. No placeholders / vague steps. -3. Type consistency across tasks (`FileWithTags`, `Set`, `Map`, etc.). -4. TDD order preserved (test before implementation in every task). -5. Commit messages match the binding-rule scope (all `feat(desktop)` / `test(desktop)` / `chore(release)`). -6. Task 8's CHANGELOG entry is accurate against the shipped behaviour. - -**After plan review passes:** offer execution choice (subagent-driven vs inline), user picks, executor skill takes over. diff --git a/docs/superpowers/specs/2026-04-15-meta-plan-design.md b/docs/superpowers/specs/2026-04-15-meta-plan-design.md deleted file mode 100644 index d9ddf59..0000000 --- a/docs/superpowers/specs/2026-04-15-meta-plan-design.md +++ /dev/null @@ -1,360 +0,0 @@ -# perima meta-plan — phase roadmap toward v1 - -**Status:** revised after reviewer pass #1; amended 2026-04-17 -**Author:** Claude Opus 4.6 (autonomous mode) -**Date:** 2026-04-15 -**Scope:** Phase-level sequencing for perima v1. Each phase below gets -its own brainstorm → spec → plan → execute cycle when it becomes the -current phase. This document is the *skeleton*, not the plan for -every phase. - ---- - -## Progress snapshot (as of 2026-04-17) - -| Phase | Status | Tags | -|---|---|---| -| 0 — Scaffold & gates | ✅ done | `phase-0-complete` | -| 1a/1b/1c — Indexing core + CLI | ✅ done | `phase-1a-complete`, `phase-1b-complete`, `phase-1-complete` | -| 2 — Tauri shell | ✅ done | `phase-2-complete` | -| 3 — Watching + incremental updates | ✅ done | `v0.3.0` through `v0.3.2` | -| 4 — Thumbnails + media metadata | ✅ done | `v0.4.0` through `v0.4.3` | -| 5a — Tags | ✅ done | `v0.5.0`, `v0.5.1` | -| 5b — Search + filter | ✅ done | `v0.6.0`, `v0.6.1` | -| 5b follow-ups (v0.6.2/v0.6.3) | in progress | (post-review cleanup; issue #25 live faceted search + issue #22 FTS5 stale-rename) | -| 6 — Local HTTP API | pending | — | -| 7 — Docs site (Starlight) | pending | — | -| 8 — Plugin API | pending | — | -| 9 — v1 hardening | pending | — | - -Update this table when a phase ships. Source of truth for detail is -CHANGELOG.md + the per-phase specs under `docs/superpowers/specs/`. - ---- - -## MVP target (v1) - -From `2026-04-09-multiplatform-rust-perima.md`: scan + BLAKE3 hashing, -SQLite storage with CRDT-ready schema, cross-volume path tracking, -thumbnails, tagging, search, file watching, local HTTP API. Desktop -only for v1. No CRDT sync, no mobile, no WASM plugins, no AI tagging, -no Obsidian plugin in v1. - -User-set constraint: v1 begins with "indexing engine + a UI"; therefore -phase 1 (CLI core) and phase 2 (Tauri shell) together form the earliest -demoable form. Subsequent phases add capability on the same base. - ---- - -## Ordering principle - -1. **Foundation before features.** Workspace, lints, CI gates (phase 0) - before any domain code — so every subsequent phase ships against - the same quality bar. -2. **Prove the core in isolation.** CLI (phase 1) verifies indexing - correctness with zero UI ambiguity. -3. **Shell over known-good core.** Tauri (phase 2) wraps an already- - working engine; UI can be ugly, the data layer cannot. -4. **Incremental capability.** Each subsequent phase adds one - independently testable concern. -5. **Hardening last.** Perf, recovery, and drive-loss edge cases - (phase 9) come after functional completeness. -6. **Core schema freezes at end of phase 1.** Phases 4+ may add new - tables (tags, media metadata, etc.) but must not alter `files`, - `volumes`, `file_locations`, or `volume_mounts` shape. This keeps - phase 6 (HTTP API) movable and prevents late schema churn. - ---- - -## Phase list - -### Phase 0 — Scaffold & gates - -- Virtual workspace at root. Empty `crates/{core,db,fs,hash,cli}`. -- Workspace `Cargo.toml` with pinned deps: `rusqlite` (bundled), - `blake3`, `walkdir`, `notify`, `notify-debouncer-full`, `uuid` (v7), - `tracing`, `tracing-subscriber`, `thiserror`, `anyhow`, `serde`, - `sysinfo`, `directories`, `unicode-normalization`, `path-slash`, - `dunce`, `file-id`, `refinery`. Pin `tauri-specta` + `tauri` at - workspace level even though they're used in phase 2. -- **Workspace lints (exact flags, enforced in CI):** - - `#![deny(rustdoc::broken_intra_doc_links)]` - - `#![deny(rustdoc::private_intra_doc_links)]` - - `#![warn(missing_docs)]` - - `cargo clippy --workspace --all-targets -- -D warnings - -W clippy::pedantic -W clippy::cognitive_complexity - -W clippy::too_many_lines -W clippy::excessive_nesting` - - Thresholds: cyclomatic <10, cognitive <15. -- `justfile` targets: `test`, `clippy`, `doctest`, `mdbook-test` - (Rust doctests on core), `verify` (kani on-demand), `ci` - (clippy + test + mdbook-test + docs-coverage), `docs-coverage`. -- **Pre-commit hook (local):** `just ci` (clippy + test + mdbook-test - + docs-coverage). `kani` never pre-commit. -- **CI:** mirror of pre-commit on every push. **Nightly job:** kani. -- `// WHY:` comment convention stated in `CLAUDE.md` (already present); - `clippy` not blocked by their absence — they are reviewer-enforced. -- `.gitignore` preserved (`**/*.md` with narrow whitelist: `CHANGELOG.md`, - `CLAUDE.md`, `docs/superpowers/**/*.md`, `docs/routines/**/*.md`, - `.claude/**/*.md`). -- ~~`DECISIONS.md` stub (gitignored).~~ **2026-04-17 revision:** a flat - DECISIONS.md was created at phase 0 but became stale after 12 entries. - Per deep-research verdict, we rely on CLAUDE.md (living rules), phase - specs (intent snapshots), and commit WHY blocks (ground truth) instead. - No separate decisions log. -- **Exit (autonomously verifiable):** `just ci` green on empty crates; - CI pipeline green; `cargo clippy` produces zero warnings; doc-coverage - script exits 0. - -### Phase 1 — Indexing core + foundation concerns (CLI-visible) - -Internally split into three plans (1a/1b/1c) per 2026-04-16 spec -reviewer — scope of phase 1 exceeds one sensible implementation plan. -Each sub-phase owns a spec + plan + reviewer pass + commits. - -**Tagging note (2026-04-17 revision):** phases 0-2 used -`phase-N-complete` milestone tags as a historical convention. From v0.3.0 -onward the project adopted **semver tags** (`v0.N.x`) with release-plz -auto-tagging on `chore(release):` commits. The `phase-N-complete` tags -remain in history as historical markers only; do not create new ones. - -- **1a** — core types, trait ports (hash + scanner + file + volume - repositories), BLAKE3 adapter, filesystem walker, path - normalization, CLI scaffold with a DB-less `scan --dry-run` that - only walks + hashes + prints. Config + logging + Ctrl-C handler + - panic hook. -- **1b** — rusqlite adapter, refinery migrations, WAL + `synchronous - = NORMAL` pragmas, `FileRepository` + `VolumeRepository` real - implementations, `scan` persists, `perima ls` reads. -- **1c** — volume detection, `volume_mounts`, per-drive - `.perima/manifest.db` creation, `perima volumes` command, - integration tests, property tests. (Shipped pre-semver as the - historical `phase-1-complete` tag; semver equivalent is v0.2.x.) - -- **Domain types** in `crates/core`: `BlakeHash`, `FileSize`, - `MediaPath`, `VolumeId`, `DeviceId`, `DiscoveredFile`, `HashedFile`. - **`Asset` type-state is deferred to phase 3** (no state - transitions exist until file-watching introduces them; - designing the state machine without a consumer is blind). -- **Trait ports** in `core`: `HashService`, `Scanner`, - `FileRepository`, `VolumeRepository`. **`EventBus` is deferred to - phase 3** (only consumer is the watcher; YAGNI until then). -- **`crates/hash`:** BLAKE3 via `blake3` crate; two-phase strategy - (first-64KB + full). -- **`crates/fs`:** `walkdir` scanner; NFC via `unicode-normalization`; - forward-slash via `path-slash`; UNC stripping via `dunce`; volume - detection via `sysinfo`; drive-identifier priority chain - (GPT GUID → fs UUID → label). -- **`crates/db`:** rusqlite adapter; **CRDT-compliant schema** per - `CLAUDE.md` rules — UUIDv7 PKs, `updated_at` + `device_id` on every - mutable row, soft deletes, **no UNIQUE on mutable columns** (the - research doc's `UNIQUE(volume_id, relative_path)` is rejected; - uniqueness enforced in application code via index lookup before - insert), **no FK cascades** (referential integrity in app layer); - migrations via `refinery`. Schema covers: `files`, `volumes`, - `file_locations`, `volume_mounts`. **This schema is frozen at - phase 1 exit.** -- **`crates/cli`:** `perima scan `, `perima ls`, `perima volumes`. -- **Per-drive manifest write:** `.perima/manifest.db` at each volume - root is created and populated during scan. Recovery logic deferred - to phase 9; creation path is phase 1. -- **Cross-cutting concerns landed here, not deferred:** - - **Config:** `directories` crate for platform paths (XDG, AppData, - Application Support). Main DB at the platform data dir. - - **Logging:** `tracing` + `tracing-subscriber` JSON output to - stderr; `RUST_LOG` env respected. - - **Error taxonomy:** `CoreError` enum in `core` with - `NotFound`, `Duplicate`, `IoError`, `HashMismatch`, `DbError` - variants via `thiserror`. `cli` converts to `anyhow` + user - messages. - - **Secrets:** none in phase 1 (API key lives in phase 6 with - OS-keychain integration specified there). -- **Tests:** unit per crate; integration hitting real SQLite temp DBs; - `proptest` for hash determinism + path round-trip + NFC - idempotence; `insta` snapshots for CLI output; manifest round-trip - test. -- **Exit (autonomously verifiable):** - - `perima scan ` populates main DB with N expected rows. - - `perima ls` stdout matches insta snapshot. - - `.perima/manifest.db` exists at fixture root with matching rows. - - `just ci` green. Proptest runs pass at default cases count. - - Zero `clippy` warnings. `missing_docs` satisfied on all public items. - -### Phase 2 — Tauri shell (thin UI) - -- `apps/desktop` (Vite + React + Tailwind + Bun). -- `crates/desktop` (Tauri backend) wiring core traits. -- `tauri-specta` for type-safe IPC (version pinned in phase 0). -- UI: single page, flat table of files (path, size, hash, volume, - last_seen). Scan trigger + folder picker. -- **Tests:** `vitest` + jsdom for React components; **headless Tauri - integration test** driving IPC via `tauri::test` — asserts that - invoking `scan` updates the exposed store and that `list_files` - returns the expected snapshot. No human-in-loop verification. -- **Exit (autonomously verifiable):** headless test scans a fixture, - asserts IPC response payload matches insta snapshot; React - component snapshot test renders table without crashing; zero - clippy/lint warnings; TSDoc on every exported symbol. - -### Phase 3 — Watching + incremental updates - -- `crates/fs` extended with `notify` + `notify-debouncer-full`. -- Rename stitching via `file-id`. -- Core reacts to events via `EventBus` trait (defined phase 1), - updates `file_locations.status` (active/missing/moved). -- CLI gains `perima watch ` (simpler surface, tested first). -- UI (Tauri) subscribes to Tauri events emitted from the same - `EventBus` adapter; reflects changes live. -- **Exit (autonomously verifiable):** integration test with a tmpfs - fixture performs 50 mutations (create/rename/delete); p95 - event-to-DB latency under 2s; status transitions match expected - table; UI headless test observes Tauri events fired with matching - payloads. - -### Phase 4 — Thumbnails + media metadata (additive schema only) - -- New `crates/media` with `image`, `kamadak-exif`, `nom-exif`. -- Thumbnail generation: on-demand + background queue (`tokio` - task via core trait). -- EXIF/video metadata stored in a **new** table `file_metadata` - (additive; does not alter `files`). -- UI: grid view with thumbnails (toggle between table/grid). -- **Exit (autonomously verifiable):** scan over a fixture produces - thumbnail bytes that decode to expected dimensions via `image`; - `file_metadata` rows present with expected EXIF values; component - snapshot for grid view; schema diff confirms `files`/`volumes`/ - `file_locations`/`volume_mounts` unchanged from phase 1. - -### Phase 5a — Tags - -- New schema tables: `tags`, `file_tags` (CRDT-ready: UUIDv7, - soft deletes, `device_id`, `updated_at`). Additive only. -- Core: tag service (add, remove, list, by-file, by-tag). -- UI: tag add/remove per file, tag list in sidebar. -- **Exit:** integration tests for tag CRUD; component snapshot for - sidebar; schema diff confirms core tables unchanged. - -### Phase 5b — Search + filter - -- SQLite FTS5 virtual table over `files.path + file_metadata + tags`. -- Core: search service with query DSL (simple: `tag:foo kind:image - free-text`). -- UI: search bar + tag filter sidebar. -- **Exit:** search integration tests with expected result sets; - component snapshot for search UI; FTS5 table rebuild test. - -### Phase 6 — Local HTTP API - -- `crates/api` with axum bound to 127.0.0.1 on configurable port. -- **API-key auth:** generated first-run, stored in OS keychain via - `keyring` crate (cross-platform: Secret Service/Keychain/Credential - Manager); fallback to config dir with `0600` perms if keychain - unavailable. **No plaintext logs.** -- CORS for `app://obsidian.md` + `http://localhost:*`. -- Endpoints: `/api/assets`, `/api/search`, `/api/tags`, - `/api/volumes`, `/api/graph`. -- WebSocket for live updates (driven by same `EventBus`). -- OpenAPI doc emission via `utoipa`. -- **Exit:** integration tests hitting the server with `reqwest`; - auth-enforcement tests; WS subscription test asserts events push; - OpenAPI spec generated. - -### Phase 7 — Docs site (Astro Starlight) - -- `docs/` Astro Starlight project (Bun-managed). -- Diátaxis structure: tutorials, how-to, reference, explanation. -- `cargo doc` output mounted under `/api/rust/`. -- TypeDoc output mounted under `/api/ts/`. -- Starlight link-check in CI. -- Note: `mdbook test` on core doctests already runs from phase 0; - this phase only ships the site. -- **Exit:** Starlight build green in CI; link-check passes; - rustdoc + TypeDoc output present under `/api/*`. - -### Phase 8 — Plugin API (Rust traits + Extism/WASM) - -- `crates/plugin-api` with `AssetProcessor`, `MetadataExtractor` - traits. -- Extism/wasmtime loader in `crates/core`. -- Reference WASM plugin (e.g., batch-rename) as smoke test. -- **Exit:** loading a sample `.wasm` runs correctly against real - assets; sandbox enforcement test. - -### Phase 9 — v1 hardening - -- Per-drive manifest **recovery** (creation already in phase 1): - OS-reinstall simulation, multi-drive match by GUID priority. -- Volume-loss / volume-reinsert edge cases. -- Performance: 100k-file scan benchmark. **Placeholder target - (revisable at phase 9 brainstorm): cold ≤ 5 min, warm ≤ 30 s on - SSD reference hardware.** -- `tracing` end-to-end audit (structured spans across crate - boundaries). -- Error-message quality pass. -- **Exit:** perf target met; recovery integration test passes; - clippy + tests + docs-coverage + Starlight link-check all green; - v1 tag cut. - -### Post-v1 (out of scope for this meta-plan) - -CRDT sync, mobile (UniFFI + Expo), Obsidian TS plugin, AI tagging, -graph visualization. These get their own meta-plan after v1 ships. - ---- - -## Cross-phase dependencies - -- Phase 0 gates every later phase. -- Phase 2 depends on phase 1 (core stable before UI). -- Phase 3 depends on phase 1 (needs scanner + `EventBus` trait) and - phase 2 (live UI). Watching CLI (`perima watch`) could ship in - phase 1 if bandwidth allows — deferred only to keep phase 1 bounded. -- Phase 4 depends on phase 2 (UI surface). -- Phases 5a/5b depend on phase 4 (tags render on grid; search - indexes metadata). -- Phase 6 depends on phase 5b (stable search surface in API). - **Because the core schema froze at phase 1, phase 6 is not blocked - by 4/5 schema drift.** -- Phase 7 depends on phase 1 (real code to document); best signal - after phase 6. -- Phase 8 depends on phase 1 (trait ports stable). -- Phase 9 depends on every prior phase being complete. - -## Per-phase execution loop (recap) - -Per `CLAUDE.md`: -`brainstorming → spec → reviewer → plan → reviewer → execute via -subagents (each task: execute → tests → reviewer → commit) → -verification-before-completion → tag phase.` - -## Risks flagged - -- **UI correctness is unverifiable without visual review.** - Mitigation (honest, consistent with autonomous-mode rule): UI phases - (2, 4, 5a, 5b) ship with **headless IPC tests + component snapshot - tests only**. Visual polish is an explicit **non-goal for v1**; - tracked post-v1 via [issue #27 (three-pane layout)](https://github.com/utof/perima/issues/27) - and its sub-issues (#28-#31, #33-#35). We do not stop for human - review, and we do not pretend tests cover aesthetics. -- **Scope creep mid-phase.** Reviewer subagent checks each task - against the phase spec, not the meta-plan. -- **Review-gate skipping.** Observed in v0.6.x: the cloud trigger - shipped 4 feat commits with zero `fix(...)` review-fix commits, vs - v0.5.1's 1:1 ratio. Mitigated 2026-04-17 via tightened rules in - `.claude/commands/autonomous-continue.md` (Rule 3: every feat - commit must be followed by two-stage review; "zero fix commits - across a phase = red flag requiring justification in release body"). - Tracked as [issue #26](https://github.com/utof/perima/issues/26). -- **Phase-plan rot.** Re-read + revise this meta-plan before starting - each phase. Originally called for full regeneration after phase 5b; - 2026-04-17 decision is to amend in place (progress snapshot, - decision-log walkback, risk updates). Regenerate only if post-v1 - scope clarifies substantially. -- **kani cost.** On-demand only, never per-commit. -- **cr-sqlite maintenance risk.** Research doc flags last release - 2025-01. v1 does not depend on cr-sqlite. Post-v1 sync phase ships - with a fallback: a hand-rolled LWW merge over `updated_at` + HLC if - cr-sqlite is dead by then. The CRDT-ready schema supports either - path; this is captured here so the meta-plan author doesn't default - to cr-sqlite. -- **tauri-specta / tauri API churn.** Versions pinned at workspace - level from phase 0; upgrades are explicit tasks, never drifted. diff --git a/docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md b/docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md deleted file mode 100644 index 607be3a..0000000 --- a/docs/superpowers/specs/2026-04-15-phase-0-scaffold-design.md +++ /dev/null @@ -1,359 +0,0 @@ -# Phase 0 — Scaffold & gates - -**Status:** draft awaiting reviewer pass -**Author:** Claude Opus 4.6 (autonomous mode) -**Date:** 2026-04-15 -**Parent meta-plan:** `2026-04-15-meta-plan-design.md` - ---- - -## Goal - -Stand up the Rust workspace, lints, `justfile`, pre-commit hook, and -GitHub Actions pipeline. Zero product code. Every gate defined in -`CLAUDE.md` must be enforced on an empty (but building) workspace by -the end of this phase. Phase 0 exists so that phase 1's first line -of real code ships under the full quality bar — not under a partial -one that gets tightened later. - -## Non-goals - -- No Tauri, no React, no Bun, no TS. Frontend toolchain arrives in - phase 2. -- No domain types, traits, adapters. Those are phase 1. -- No `xtask` crate. Deferred until shell/`just` stop scaling. -- No migrations, no DB code. Phase 1. -- No kani proofs. Wiring only — first proof is phase 1 property. - -## Deliverables - -### 1. Workspace manifest - -`Cargo.toml` at repo root (virtual): - -```toml -[workspace] -members = ["crates/*"] -resolver = "3" - -[workspace.package] -edition = "2024" -rust-version = "1.85" -license = "MIT OR Apache-2.0" -repository = "https://github.com/utof/perima" - -[workspace.lints.rust] -missing_docs = "deny" -unsafe_code = "deny" - -[workspace.lints.rustdoc] -broken_intra_doc_links = "deny" -private_intra_doc_links = "deny" - -[workspace.lints.clippy] -pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -cognitive_complexity = "warn" -too_many_lines = "warn" -excessive_nesting = "warn" -module_name_repetitions = "allow" -missing_errors_doc = "warn" - -[workspace.dependencies] -# Pinned against crates.io as of 2026-04-15 — upgrades are explicit -# tasks, never drifted. rusqlite held at 0.38 to pair with refinery 0.9 -# (refinery-core 0.9.1 caps rusqlite at 0.38; bump both together when -# refinery 0.10 ships). -anyhow = "1" -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tokio = { version = "1", features = ["full"] } -uuid = { version = "1", features = ["v4", "v7", "serde"] } -blake3 = "1" -rusqlite = { version = "0.38", features = ["bundled"] } -refinery = { version = "0.9", features = ["rusqlite"] } -walkdir = "2" -notify = "8.2" -notify-debouncer-full = "0.7" -sysinfo = "0.38" -directories = "6" -unicode-normalization = "0.1" -path-slash = "0.2" -dunce = "1" -file-id = "0.2" -# Phase 2+ deps pinned now to stabilize the lockfile early. -# tauri-specta is exact-pinned because 2.x is still in rc. -tauri = { version = "2", features = [] } -tauri-specta = "=2.0.0-rc.24" - -[profile.release] -lto = "thin" -codegen-units = 1 -``` - -### 2. Empty crates - -`crates/{core,db,fs,hash,cli}` each with: -- `Cargo.toml` inheriting `[package].edition.workspace = true` etc. -- `src/lib.rs` (or `src/main.rs` for `cli`) containing only - `#![cfg_attr(not(any(test, feature = "test")), deny(missing_docs))]` - commented out for now (lints come from workspace); a doc-comment - crate-level description; a single placeholder item with a doc - comment so `missing_docs` has something to pass against. - -Example `crates/core/src/lib.rs`: - -```rust -//! Domain types and trait ports for perima. -//! -//! This crate has zero framework dependencies. Every other crate in -//! the workspace either defines types consumed here or adapts this -//! crate's traits to a concrete backend. - -/// Marker placeholder. Replaced with real domain types in phase 1. -pub const CRATE_NAME: &str = "perima-core"; -``` - -### 3. `justfile` - -```just -set shell := ["bash", "-eo", "pipefail", "-c"] - -default: ci - -test: - cargo test --workspace --all-targets - -clippy: - cargo clippy --workspace --all-targets -- -D warnings - -doctest: - cargo test --workspace --doc - -mdbook-test: - cargo test --workspace --doc -- --show-output - -docs-coverage: - cargo doc --workspace --no-deps - -fmt-check: - cargo fmt --all -- --check - -fmt: - cargo fmt --all - -ci: fmt-check clippy test doctest docs-coverage - -verify: - @if command -v cargo-kani >/dev/null 2>&1; then \ - cargo kani --workspace; \ - else \ - echo "cargo-kani not installed; skipping"; \ - fi - -install-hooks: - cp scripts/pre-commit .git/hooks/pre-commit - chmod +x .git/hooks/pre-commit - -test-hook: - bash scripts/test-precommit-hook.sh -``` - -Note: `mdbook-test` is currently an alias for `cargo test --doc` — the -`mdbook test` binary is only meaningful once the Starlight site has -mdbook-sourced pages (phase 7). Keeping the target name stable lets -phase 7 swap the implementation without touching CI wiring. - -### 4. Pre-commit hook (`scripts/pre-commit`) - -```bash -#!/usr/bin/env bash -set -euo pipefail -# Git hooks run in non-interactive shells that may not source ~/.bashrc, -# so cargo-installed binaries may not be on PATH. Prepend the standard -# location explicitly to make this hook portable across shells / git GUIs. -export PATH="$HOME/.cargo/bin:$PATH" -just ci -``` - -Installed via `just install-hooks`. Native git hook only — no external -`pre-commit` framework (no Python dep). - -**Known gap (logged in `DECISIONS.md`):** the pre-commit hook enforces -clippy + tests but does NOT enforce the reviewer-approval step from -CLAUDE.md ("execute → tests green → reviewer approves → commit"). -Reviewer approval is enforced by process (subagent dispatch before -`git commit`), not by the hook. A future hook enhancement may check -for a `.review-token` artifact; deferred. - -### 4a. Scripted pre-commit test (`scripts/test-precommit-hook.sh`) - -Makes exit criterion 9 autonomously verifiable. Runs against the real -workspace (`just ci` needs the justfile, Cargo.toml, and crates to be -present), plants a file that is guaranteed to fail `fmt --check`, -asserts `just ci` fails, then cleans up. Does not touch git state. - -```bash -#!/usr/bin/env bash -set -euo pipefail -# Same rationale as scripts/pre-commit: ensure cargo-installed binaries -# (notably `just`) are reachable when this script runs from a nested or -# non-interactive shell. -export PATH="$HOME/.cargo/bin:$PATH" -cd "$(git rev-parse --show-toplevel)" - -# Target an existing tracked file so cargo fmt actually inspects it. -target="crates/core/src/lib.rs" -if [[ ! -f "$target" ]]; then - echo "FAIL: expected $target to exist (phase 0 scaffolding incomplete)" >&2 - exit 2 -fi - -# Baseline: just ci must be green before we plant a violation. -just ci >/dev/null -echo "baseline: just ci green" - -# Back up and plant a guaranteed fmt violation by appending a line -# with trailing whitespace + a tab — cargo fmt --check will flag it. -backup="$(mktemp)" -cp "$target" "$backup" -trap 'cp "$backup" "$target"; rm -f "$backup"' EXIT INT TERM HUP -printf '\npub const __HOOK_TEST: i32 =\t0 ; \n' >> "$target" - -# Hook body is `just ci`; testing just ci with a violation is -# equivalent to testing the hook would block that commit. -set +e -just ci >/dev/null 2>&1 -rc=$? -set -e -if [[ $rc -eq 0 ]]; then - echo "FAIL: just ci passed with a planted fmt violation" >&2 - exit 1 -fi -echo "OK: just ci blocked a commit-equivalent with planted violation (rc=$rc)" -``` - -The planted text is appended to `crates/core/src/lib.rs` — a file -that is reachable from the workspace and inspected by -`cargo fmt --all -- --check`. The trap restores the original contents -whether the script succeeds or fails. - -### 5. GitHub Actions - -`.github/workflows/ci.yml`: -- Triggers: `push` to main, `pull_request` (even though we don't use - PRs, the main-only rule doesn't preclude external contributors later). -- Matrix: `ubuntu-latest`, `macos-latest`, `windows-latest` on stable Rust. -- Steps: checkout → `dtolnay/rust-toolchain@stable` with `clippy` + - `rustfmt` → `Swatinem/rust-cache@v2` → `just ci`. - -`.github/workflows/kani.yml`: -- Trigger: `schedule: cron: "0 3 * * *"` (nightly) + `workflow_dispatch`. -- Runs `cargo kani --workspace` on `ubuntu-latest` only. -- Does **not** block main. - -### 6. `rustfmt.toml` - -```toml -edition = "2024" -max_width = 100 -use_field_init_shorthand = true -use_try_shorthand = true -imports_granularity = "Crate" -group_imports = "StdExternalCrate" -``` - -(`imports_granularity` and `group_imports` are nightly-only rustfmt -features; keep but tolerate they're no-ops on stable toolchain. The -stable parts still apply.) - -### 7. `.gitattributes` - -Pin line endings to LF so Windows runners don't break `fmt-check` on -autocrlf round-trip. - -``` -* text=auto eol=lf -*.sh text eol=lf -*.bat text eol=crlf -``` - -### 7a. `.gitignore` additions - -Existing `**/*.md` preserved. Append: - -``` -target/ -.DS_Store -*.swp -``` - -`Cargo.lock` is **committed** (workspace ships a binary). - -### 8. `DECISIONS.md` (local, gitignored) - -Seed one ADR-format entry (date, context, decision, alternatives, -consequences) for each of: - -- Rust edition 2024, resolver 3, `rust-version = "1.85"`. -- No `crates/xtask` yet; `justfile` is the build automation surface. -- Native git pre-commit hook (no Python `pre-commit` framework). -- Pre-commit enforces `just ci` only; **reviewer-attestation is NOT - enforced by the hook** — it is a process rule executed via - subagent dispatch before `git commit`. Future hook may check for - a `.review-token` artifact; deferred. -- GitHub Actions for CI (matches repo host). -- `rustfmt.toml` with nightly-only options accepted as inert on - stable. -- `rusqlite = "0.38"` pinned to pair with `refinery = "0.9"`; bump - both when `refinery` 0.10 ships with 0.39+ support. -- Migrations via `refinery` (SQL files, not Rust code). -- Cargo.lock is committed (workspace ships binaries). - -### 9. `README.md` (local, gitignored) - -Local scratchpad only — never committed. One-liner pointing to -`CLAUDE.md` and the meta-plan spec. - -## Tests in phase 0 - -- Zero unit tests (no domain code). -- `cargo test` must still pass (it will — no tests to fail). -- `just ci` green on a fresh clone + `cargo build --workspace`. - -## Exit criteria (autonomously verifiable) - -1. `cargo build --workspace` succeeds from a fresh clone. -2. `cargo clippy --workspace --all-targets -- -D warnings` exits 0. -3. `cargo test --workspace` exits 0 (no tests, still success). -4. `cargo fmt --all -- --check` exits 0. -5. `cargo doc --workspace --no-deps` produces zero `missing - documentation` warnings. -6. `just ci` exits 0. -7. GitHub Actions `ci.yml` workflow is green on the first pushed - commit across all three OS runners. -8. GitHub Actions `kani.yml` is present and syntactically valid - (does not need to have run yet). -9. `just test-hook` exits 0 (scripted pre-commit test at - `scripts/test-precommit-hook.sh` confirms the hook blocks a - bad commit). -10. `.gitignore` still excludes `**/*.md`; no `.md` file appears in - `git status --porcelain` output after all phase 0 edits. - -## Risks - -- **Rustfmt nightly options on stable toolchain.** Mitigation: - accept they're inert on stable; document in `DECISIONS.md`. -- **Clippy pedantic noise.** Mitigation: `module_name_repetitions` - alone is allowed; `missing_errors_doc` is `warn` (per CLAUDE.md's - strict doc rule). Extend the allow-list only via explicit task in - phase 1 if a lint genuinely blocks real work, with justification - in `DECISIONS.md`. -- **CI matrix flakiness on Windows/macOS for greenfield.** Mitigation: - none preemptive. If a runner fails for environmental reasons, open - a phase-0 task to diagnose — do not mask with `continue-on-error`. -- **`just ci` runtime.** Should stay under 30 s on empty workspace. - If it doesn't, investigate before phase 1. diff --git a/docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md b/docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md deleted file mode 100644 index 451c872..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-1a-core-scan-cli-design.md +++ /dev/null @@ -1,538 +0,0 @@ -# Phase 1a — Core types, ports, scan-without-DB - -**Status:** draft awaiting reviewer pass #2 -**Date:** 2026-04-16 -**Parent:** meta-plan `2026-04-15-meta-plan-design.md`, phase 1 entry. -**Prior phase:** `phase-0-complete` tag. -**Siblings:** 1b (DB + `perima ls`), 1c (volumes + manifest + -`perima volumes`). Written just-in-time after each predecessor lands. - ---- - -## Goal - -Land the hexagonal core (domain types + trait ports) and the two -purest adapters (hash, fs walker), plus enough of the CLI to exercise -them. `perima scan --dry-run ` walks the tree, BLAKE3-hashes -each file, and prints ` ` to stdout -plus a summary to stderr. **No database writes in 1a.** 1b wires the -real repository; 1c wires volume identification. - -All cross-cutting concerns (config, logging, errors, Ctrl-C handler, -panic hook) land in 1a because retrofitting them later would touch -every command. - -## Non-goals for 1a - -- rusqlite, refinery, schema, migrations (1b). -- Volume detection, `volume_mounts`, `.perima/manifest.db` (1c). -- `perima ls`, `perima volumes` subcommands (1b, 1c). -- File watching, `EventBus`, `Asset` (phase 3; deferred). -- Thumbnails, tags, FTS5, Tauri (later phases). - ---- - -## Architecture - -``` -crates/core -├── ids.rs // UUIDv7 helpers (new_id, parse) -├── errors.rs // CoreError (zero adapter deps) -├── types.rs // BlakeHash, FileSize, MediaPath, -│ // VolumeId, DeviceId, DiscoveredFile, -│ // HashedFile -├── ports/ -│ ├── hash.rs // HashService trait -│ ├── scanner.rs // Scanner trait -│ ├── file_repo.rs // FileRepository trait (SHAPE ONLY in 1a) -│ └── volume_repo.rs // VolumeRepository trait (SHAPE ONLY in 1a) -└── lib.rs // re-exports - -crates/hash -├── errors.rs // hash::Error -└── blake3_service.rs // Blake3Service impls HashService (rayon) - -crates/fs -├── errors.rs // fs::Error -├── paths.rs // MediaPath normalization (NFC + /) -└── walker.rs // WalkdirScanner impls Scanner - -crates/cli -├── config.rs // data_dir / config_dir / device_id -├── logging.rs // tracing-subscriber init -├── signals.rs // Ctrl-C handler (AtomicBool flag) -├── panic.rs // std::panic::set_hook → tracing::error -├── cmd/ -│ └── scan.rs // `perima scan` (dry-run only in 1a) -└── main.rs // clap root + dispatch -``` - -Trait ports for `FileRepository` and `VolumeRepository` are defined -in 1a (signatures only) so the scan command can depend on abstract -interfaces. 1a's CLI does not instantiate them — `scan --dry-run` -short-circuits before the repository boundary. - -### Concurrency - -Sync throughout. `rayon` for parallel file hashing inside the scan -loop. No tokio, no async. `HashService: Send + Sync` permits a future -async adapter without breaking the trait. - -### Data flow (`perima scan --dry-run `) - -1. `main.rs` installs panic hook + Ctrl-C handler; initializes - logging; parses CLI; resolves config. -2. `scan::run(path, dry_run=true)` validates the path (exists + is a - directory; else exit 2). -3. `WalkdirScanner` produces an iterator of `DiscoveredFile`. -4. For each, `rayon::par_iter` with a buffered channel drains - `DiscoveredFile` → `Blake3Service::full_hash` → `HashedFile`. -5. Each `HashedFile` is printed to stdout as - ` `. -6. On completion (or on Ctrl-C drain), print to stderr: - `scanned files (dry-run; DB not yet wired)`. -7. Exit 0 on success; exit 130 if interrupted (Ctrl-C); exit 1 on - unrecoverable error. - -Ctrl-C: the handler flips an `AtomicBool`. The scan loop polls it -between batches; when set, the iterator stops early, the drain -completes printing already-hashed entries, and the summary reflects -the partial count. - ---- - -## Domain types (`crates/core/types.rs`) - -All pub items have doc comments (workspace `missing_docs = "deny"`). -WHY-comments call out non-obvious design choices per CLAUDE.md. - -```rust -/// BLAKE3-256 content hash (32 bytes). Stored as lowercase hex at -/// the persistence boundary. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct BlakeHash([u8; 32]); -impl BlakeHash { - pub fn from_bytes(b: [u8; 32]) -> Self; - pub fn to_hex(&self) -> String; // 64-char lowercase - pub fn parse_hex(s: &str) -> Result; - pub fn as_bytes(&self) -> &[u8; 32]; -} - -/// File size in bytes. Newtype to prevent arithmetic with other u64s. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct FileSize(pub u64); - -/// Path relative to a volume root. NFC-normalized, forward-slash, -/// no leading slash. The constructor is *idempotent* AND makes -/// canonically-equivalent inputs compare equal (NFC = NFD). -#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct MediaPath(String); -impl MediaPath { - pub fn new(raw: &str) -> Self; - pub fn as_str(&self) -> &str; -} - -/// UUIDv7 volume identifier (phase 1c populates; 1a only declares). -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct VolumeId(pub uuid::Uuid); -impl VolumeId { pub fn new() -> Self; } // uuid::Uuid::now_v7() - -/// UUIDv7 device identifier. Read from or created at -/// `/perima/device_id.txt` on first run. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] -pub struct DeviceId(pub uuid::Uuid); - -/// Output of the scanner; pre-hash. -pub struct DiscoveredFile { - pub absolute_path: std::path::PathBuf, - pub relative_path: MediaPath, - pub size: FileSize, -} - -/// Post-hash pipeline record. -pub struct HashedFile { - pub discovered: DiscoveredFile, - pub hash: BlakeHash, -} -``` - -## Trait ports - -```rust -// crates/core/ports/hash.rs -pub trait HashService: Send + Sync { - /// Hash only the first 64 KiB. Cheap change-detection. Phase 3's - /// watcher uses this; 1a's scan always calls `full_hash`. - fn quick_hash(&self, path: &std::path::Path) -> Result; - /// Hash the entire file. - fn full_hash(&self, path: &std::path::Path) -> Result; -} - -// crates/core/ports/scanner.rs -pub trait Scanner: Send + Sync { - /// Walk `root` recursively. Per-entry I/O errors are logged via - /// tracing and skipped; the iterator continues. Only terminal - /// failures (permission-denied on the root, etc.) return Err. - fn walk( - &self, - root: &std::path::Path, - volume_root: &std::path::Path, - ) -> Result + '_>, CoreError>; -} - -// crates/core/ports/file_repo.rs (SHAPE ONLY — 1a does not -// instantiate; 1b wires the real impl.) -pub trait FileRepository: Send + Sync { - fn upsert_file( - &mut self, - file: &HashedFile, - device: DeviceId, - ) -> Result; - fn upsert_location( - &mut self, - hash: &BlakeHash, - volume: VolumeId, - path: &MediaPath, - device: DeviceId, - ) -> Result; - /// Phase 1b uses this for `perima ls`. Returns one row per - /// (hash, volume, relative_path) with the `status` attached. - fn list_file_locations( - &self, - limit: usize, - volume: Option, - ) -> Result, CoreError>; -} - -pub enum UpsertOutcome { Inserted, Updated, Unchanged } - -/// A joined view of `files` + `file_locations`. The `status` field -/// belongs to the *location*, not the file — a single file's hash -/// may live on multiple volumes with independent statuses. -pub struct FileLocationRecord { - pub hash: BlakeHash, - pub size: FileSize, - pub volume_id: VolumeId, - pub relative_path: MediaPath, - pub status: LocationStatus, - pub first_seen: String, // ISO 8601 UTC -} - -pub enum LocationStatus { Active, Missing, Moved } - -// crates/core/ports/volume_repo.rs (SHAPE ONLY) -pub trait VolumeRepository: Send + Sync { - fn find_or_create( - &mut self, - ident: &VolumeIdentifiers, - device: DeviceId, - ) -> Result; - fn record_mount( - &mut self, - volume: VolumeId, - machine: DeviceId, - mount: &std::path::Path, - ) -> Result<(), CoreError>; - fn list(&self) -> Result, CoreError>; -} - -pub struct VolumeIdentifiers { - pub gpt_partition_guid: Option, - pub fs_uuid: Option, - pub label: Option, - pub capacity_bytes: u64, - pub is_removable: bool, -} - -pub struct VolumeRecord { - pub id: VolumeId, - pub label: Option, - pub capacity_bytes: u64, - pub is_removable: bool, - pub mounts_on_this_machine: Vec, - pub last_seen: String, // ISO 8601 UTC -} -``` - ---- - -## Error taxonomy - -`core` stays framework-free. Adapters each define their own `Error` -and a `From for CoreError` conversion **inside the adapter -crate** (adapter depends on core; never the other way). - -```rust -// crates/core/errors.rs -#[derive(Debug, thiserror::Error)] -pub enum CoreError { - #[error("not found: {0}")] NotFound(String), - #[error("duplicate: {0}")] Duplicate(String), - #[error("invalid path: {0}")] InvalidPath(String), - #[error("invalid hash hex: {0}")] InvalidHash(String), - #[error("io: {0}")] Io(#[from] std::io::Error), - #[error("internal: {0}")] Internal(String), -} - -// crates/hash/errors.rs -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("io: {0}")] Io(#[from] std::io::Error), -} -impl From for perima_core::CoreError { /* … */ } - -// crates/fs/errors.rs -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("io: {0}")] Io(#[from] std::io::Error), - #[error("path not under volume root: {0}")] - NotUnderVolume(std::path::PathBuf), -} -impl From for perima_core::CoreError { /* … */ } -``` - -Phase 1b will add richer DB-error mapping -(`rusqlite::Error::QueryReturnedNoRows` → `CoreError::NotFound`, -constraint failures → `CoreError::Duplicate`). Called out here so -1b's spec picks it up. - ---- - -## CLI in 1a — `perima scan --dry-run ` - -Only one subcommand in 1a, and only its `--dry-run` form. - -Options: -- `` (positional, required) — directory to walk. -- `--dry-run` (required in 1a; 1b makes it optional, default off). -- `--data-dir ` — accepted but unused in 1a; reserved for 1b. -- `-v` / `-vv` — tracing level bumps. -- `--quiet` — suppress per-file stdout lines, print summary only. - -Behavior: walk + hash + print per-file to stdout; summary to stderr. -No DB access at all. The `--dry-run` flag is the autonomously- -verifiable proxy for "no repository used": if `--dry-run` is absent -in 1a, the binary exits 2 with -`phase 1a ships only 'scan --dry-run'; real scan arrives in 1b`. -1b removes that guard and makes `--dry-run` optional. - ---- - -## Cross-cutting concerns - -### Config (`crates/cli/config.rs`) - -```rust -pub struct Config { - pub data_dir: std::path::PathBuf, - pub config_dir: std::path::PathBuf, - pub device_id: perima_core::DeviceId, -} -impl Config { - /// Resolve from the `directories` crate, then env overrides - /// (`PERIMA_DATA_DIR`, `PERIMA_CONFIG_DIR`), then CLI flag - /// overrides. Creates `/device_id.txt` on first run. - pub fn resolve( - cli_data_dir: Option, - ) -> Result; -} -``` - -### Logging (`crates/cli/logging.rs`) - -```rust -/// Init `tracing-subscriber`. Reads `PERIMA_LOG` (env filter, -/// default "info"); `PERIMA_LOG_JSON=1` for JSON output (else -/// human-readable text). Writes to stderr. `verbosity_bump` comes -/// from CLI `-v` count and is additive on the `perima` target. -pub fn init(verbosity_bump: u8) -> Result<(), perima_core::CoreError>; -``` - -### Ctrl-C handler (`crates/cli/signals.rs`) - -```rust -/// Install a SIGINT/SIGTERM handler that flips a global AtomicBool. -/// The scan loop polls `cancelled()` between batches and exits -/// gracefully, letting already-hashed entries finish printing. -/// Returns a `Cancellation` guard that owns the registered handler; -/// dropping it removes the handler (important for tests). -pub fn install() -> Result; - -pub struct Cancellation; -impl Cancellation { - pub fn cancelled(&self) -> bool; -} -``` - -Implementation via the `ctrlc` crate (widely used, small). Add to -`[workspace.dependencies]`. - -### Panic hook (`crates/cli/panic.rs`) - -```rust -/// Install a panic hook that routes panics through -/// `tracing::error!` with backtrace + thread info. Replaces the -/// default "thread 'xxx' panicked at …" so background rayon -/// threads don't die silently. -pub fn install(); -``` - ---- - -## WHY-comments required in code - -Per CLAUDE.md, non-obvious choices get `// WHY:` comments. 1a's code -must include WHY-comments for at least: - -- `MediaPath::new` combining NFC + forward-slash + leading-slash - strip — WHY: the combination is what makes the constructor - idempotent AND makes NFC-vs-NFD inputs compare equal. -- `HashService: Send + Sync` despite sync-only phase — WHY: leaves - room for a future async adapter without breaking the trait. -- Ctrl-C handler flipping an AtomicBool rather than aborting — - WHY: lets already-hashed entries finish printing so stdout isn't - truncated mid-line. -- `files.blake3_hash` as PK (deferred to 1b's migration) — WHY: a - content hash is deterministic and content-derived; two devices - hashing identical bytes MUST produce the same row under any CRDT - merge strategy, so a content-address PK is effectively a - deterministic UUID and satisfies the "no accidental divergence - under merge" invariant that the UUIDv7 rule exists to enforce. - 1a doesn't land the migration but this WHY is documented here so - 1b reproduces it verbatim at the migration site. - ---- - -## Test strategy (1a) - -All tests run under `cargo test --workspace --all-targets` as part -of `just ci` — no new harness needed. - -### Unit tests - -- `crates/core/src/types.rs`: - - `BlakeHash::parse_hex` round-trips with `to_hex`. - - `BlakeHash::parse_hex` rejects wrong length and non-hex chars. - - `FileSize` derive traits compile (trivial). -- `crates/fs/src/paths.rs`: - - `MediaPath::new` forward-slash conversion on Windows-style input. - - `MediaPath::new` strips leading slash. - - `MediaPath::new("café")` equals `MediaPath::new("cafe\u{0301}")` - (fixed-case NFC equivalence — paired with the proptest below). - -### Property tests (in `crates/core/tests/props_*.rs`) - -- **Hash determinism**: `proptest!` with 256 cases — same `Vec` - input produces the same `BlakeHash` across calls. -- **Path idempotence**: `proptest!` with 256 cases — - `MediaPath::new` applied twice equals applied once. -- **Path NFC equivalence**: `proptest!` with 256 cases — for any - string `s`, `MediaPath::new(s) == MediaPath::new(to_nfd(s))`. - *This is the property that actually prevents de-dup misses when - macOS (NFD) and Linux (NFC) paths reference the same asset.* - -### Integration tests (`crates/cli/tests/scan_dry_run.rs`) - -- Create a `tempfile::tempdir()` with three fixture files of known - contents. -- Invoke `perima scan --dry-run ` via `std::process::Command` - pointing at the built binary (`env!("CARGO_BIN_EXE_perima")`). -- Assert: - - Exit code 0. - - Stdout, **sorted line-wise**, contains three lines each matching - `^[0-9a-f]{64} \d+ .+$`. (Sort first because rayon parallel - hashing means output order is non-deterministic.) - - Stderr ends with `scanned 3 files (dry-run; DB not yet wired)`. - - Running twice produces identical *sorted* stdout (determinism - through the CLI, not just the library). - - `perima scan ` (no `--dry-run`) exits 2 with stderr - containing `phase 1a ships only 'scan --dry-run'`. - -- Snapshot the summary line via `insta` (hash values are - content-derived and stable, so no redaction needed for hashes; - the temp-dir path in relative-path components IS redacted via - an `insta::with_settings!` filter). - ---- - -## Dependencies to add - -Append to workspace `[workspace.dependencies]`: - -```toml -clap = { version = "4", features = ["derive"] } -tempfile = "3" -insta = { version = "1", features = ["yaml", "filters"] } -proptest = "1" -rayon = "1" -ctrlc = { version = "3", features = ["termination"] } -``` - -Existing workspace deps (uuid, tracing, tracing-subscriber, blake3, -walkdir, unicode-normalization, path-slash, dunce, thiserror, -anyhow, serde, serde_json, directories) cover everything else. - ---- - -## Exit criteria (phase 1a, autonomously verifiable) - -1. `cargo build --workspace` — exit 0. -2. `cargo clippy --workspace --all-targets -- -D warnings` — exit 0. -3. `cargo test --workspace` — exit 0; new property tests pass with - default 256 cases; integration test passes. -4. `cargo doc --workspace --no-deps` — exit 0 (every new public item - has a doc comment). -5. `perima scan --dry-run ` stdout has one - `^[0-9a-f]{64} \d+ .+$` line per fixture file and stderr ends - with the expected summary; `perima scan ` (without - `--dry-run`) exits 2 with the phase-1a guard message. -6. Ctrl-C handling: unit test of the `Cancellation` flag passes on - all platforms. Signal-dispatch integration test gated behind - `#[cfg(unix)]` passes on Linux/macOS CI; skipped on Windows. -7. `just ci` green. -8. `grep -rE '^\s*//\s*WHY:' crates/` finds **at least 4** WHY - comments (covering the four bullets in "WHY-comments required in - code"; autonomously-verifiable floor). -9. Pre-commit hook + GitHub Actions remain green after push. - ---- - -## Open decisions pushed to 1b - -- `rusqlite` journal mode: **WAL** + `synchronous = NORMAL` set via - `PRAGMA` at connection open. -- `rusqlite` distribution: **bundled + glibc desktop targets only** - for v1; musl / cross-compile deferred to post-v1 packaging. -- Integration-test DB assertions: **use `rusqlite` from the test** - (not `sqlite3` CLI — removes one install dependency). -- Rich rusqlite-error mapping: `QueryReturnedNoRows` → - `CoreError::NotFound`, constraint failures → `CoreError::Duplicate`, - everything else → `CoreError::Internal`. - -## Open decisions pushed to 1c - -- Volume identifier priority chain concrete algorithm (GPT GUID → fs - UUID → volume label) AND **conflict** resolution (GUID wins when - different identifiers each match a different known volume). -- Per-drive manifest schema (subset of main DB: `manifest_meta` + - `manifest_files`). - ---- - -## Risks (1a-specific) - -- **Rayon + per-file stdout ordering.** Parallel hashing means - output order won't match walk order. Mitigation: accept the - non-determinism; integration test sorts lines before comparing. -- **NFC proptest generating exotic Unicode.** `proptest::string::*` - ranges can produce combining characters that blow assumptions. - Mitigation: curated BMP-restricted strategy plus one fixed case - per known equivalence pair (the deterministic cases catch - regressions; random cases catch surprises). -- **Ctrl-C on Windows.** `ctrlc` is cross-platform but signal - semantics differ. Mitigation: dispatch test `#[cfg(unix)]`; - Windows coverage limited to the `Cancellation`-flag unit test. -- **Clippy pedantic noise.** Pedantic lints may fire on idiomatic - patterns (`must_use_candidate`). Mitigation: document any lint - additions to the allow-list in `DECISIONS.md` as encountered; - do NOT pre-emptively allow. diff --git a/docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md b/docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md deleted file mode 100644 index 8a2fd3e..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-1b-db-ls-design.md +++ /dev/null @@ -1,433 +0,0 @@ -# Phase 1b — DB adapter + `perima ls` - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** meta-plan, phase 1 entry (split into 1a/1b/1c). -**Prior:** `phase-1a-complete` tag. -**Sibling:** 1c (volumes + manifest + `perima volumes`). - ---- - -## Goal - -Wire `crates/db` as a real rusqlite adapter implementing -`FileRepository`. `perima scan ` (without `--dry-run`) walks, -hashes, and persists `files` + `file_locations` rows. `perima ls` -reads and prints them. The phase-1a `--dry-run` guard is removed; -`--dry-run` becomes optional (default off). - -## Non-goals for 1b - -- Volume detection, `VolumeRepository` impl, `volume_mounts`, - `.perima/manifest.db` (1c). -- `perima volumes` command (1c). -- File watching, EventBus (phase 3). -- Thumbnails, tags, FTS5 (phases 4-5). - ---- - -## Architecture - -`crates/db` gains: -- `errors.rs` — `db::Error` with rich `From for CoreError` - mapping (`QueryReturnedNoRows` → `NotFound`, constraint → - `Duplicate`, else `Internal`). -- `migrations/V001__initial.sql` — tables `files`, `file_locations` - (CRDT-ready per CLAUDE.md). `volumes` + `volume_mounts` also - created here so migration V001 is the single initial schema - (1c only populates them, not creates them). -- `connection.rs` — connection factory with WAL + `synchronous = - NORMAL` pragmas. -- `file_repo.rs` — `SqliteFileRepository` impls `FileRepository`. - -`crates/cli` changes: -- `cmd/scan.rs` — remove phase-1a `Unsupported` guard; `--dry-run` - becomes optional (default false); when false, construct - `SqliteFileRepository` and persist. -- `cmd/ls.rs` — new `perima ls` command. -- `main.rs` — add `Ls` variant to `Command` enum. - -### Connection + pragmas - -```rust -// crates/db/connection.rs -use rusqlite::Connection; -use crate::errors::Error; - -/// Open (or create) the main database at `path` with production -/// pragmas. -/// -/// WHY WAL: Write-Ahead Logging allows concurrent reads during a -/// scan write transaction. Without it, SQLite's default rollback -/// journal serializes all access, making `perima ls` block while -/// `perima scan` is running. -/// -/// WHY synchronous=NORMAL: under WAL mode, NORMAL is safe against -/// data loss on process crash (only OS crash can lose the last -/// transaction). FULL would fsync every commit — measurably slower -/// on the 100k-file scan target and unnecessary for a local -/// index that can be rebuilt from source files. -pub fn open(path: &std::path::Path) -> Result { - let conn = Connection::open(path)?; - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL; - PRAGMA foreign_keys = OFF;" - )?; - Ok(conn) -} -``` - -`foreign_keys = OFF` because CLAUDE.md says "no FK cascades" and -the CRDT-ready schema enforces referential integrity in app code. - -### Migration strategy - -`refinery` with SQL files under `crates/db/migrations/`. The runner -lives in a public `db::migrate(conn: &mut Connection) -> Result<(), -Error>` function. The CLI calls it from a shared -`db::open_and_migrate(path)` helper that combines `connection::open` -+ `migrate`. **Both `cmd/scan.rs` (when `dry_run` is false) and -`cmd/ls.rs` call `open_and_migrate` before any query** — so -`perima ls` on a fresh install creates the DB and runs migrations -rather than crashing with "no such table." - ---- - -## Schema (`crates/db/migrations/V001__initial.sql`) - -Exact SQL per CLAUDE.md rules (UUIDv7 PKs except content-addressed -`blake3_hash`, `updated_at` + `device_id` on every mutable row, -soft deletes, no UNIQUE on mutable columns, no FK cascades): - -```sql --- WHY: blake3_hash is the PK on files because a BLAKE3-256 hash is --- deterministic and content-derived — two devices hashing identical --- bytes MUST compute the same value, making it CRDT-merge-safe --- (effectively a deterministic UUID). The UUIDv7 rule applies only --- to rows whose identity is NOT content-derived. -CREATE TABLE files ( - blake3_hash TEXT PRIMARY KEY, - file_size INTEGER NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE TABLE file_locations ( - id TEXT PRIMARY KEY, - blake3_hash TEXT NOT NULL, - volume_id TEXT NOT NULL, - relative_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - last_verified TEXT, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - --- Non-unique indexes for read performance. -CREATE INDEX idx_file_locations_blake3 - ON file_locations(blake3_hash); -CREATE INDEX idx_file_locations_volume_path - ON file_locations(volume_id, relative_path); - --- Created now so migration is one-shot; populated by 1c. -CREATE TABLE volumes ( - volume_id TEXT PRIMARY KEY, - gpt_partition_guid TEXT, - fs_uuid TEXT, - volume_label TEXT, - capacity_bytes INTEGER NOT NULL, - is_removable INTEGER NOT NULL, - last_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE TABLE volume_mounts ( - id TEXT PRIMARY KEY, - volume_id TEXT NOT NULL, - machine_id TEXT NOT NULL, - mount_path TEXT NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - -CREATE INDEX idx_volume_mounts_volume_machine - ON volume_mounts(volume_id, machine_id); -``` - ---- - -## Error taxonomy (`crates/db/errors.rs`) - -```rust -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("rusqlite: {0}")] - Rusqlite(#[from] rusqlite::Error), - #[error("refinery: {0}")] - Refinery(Box), - #[error("app-level duplicate: {0}")] - AppLevelDuplicate(String), -} - -impl From for perima_core::CoreError { - fn from(e: Error) -> Self { - match e { - Error::Rusqlite(ref inner) => { - use rusqlite::Error as RE; - match inner { - RE::QueryReturnedNoRows => - perima_core::CoreError::NotFound(e.to_string()), - RE::SqliteFailure(f, _) - if f.code == rusqlite::ErrorCode::ConstraintViolation => - perima_core::CoreError::Duplicate(e.to_string()), - _ => perima_core::CoreError::Internal(e.to_string()), - } - } - Error::AppLevelDuplicate(s) => - perima_core::CoreError::Duplicate(s), - Error::Refinery(_) => - perima_core::CoreError::Internal(e.to_string()), - } - } -} -``` - ---- - -## `SqliteFileRepository` (`crates/db/file_repo.rs`) - -Implements `FileRepository` trait from `perima-core`. - -### `upsert_file` - -Two-statement approach (SELECT-then-INSERT/UPDATE) because SQLite's -`changes()` cannot distinguish a fresh INSERT from a conflict- -triggered UPDATE — both report 1. - -``` -BEGIN; -SELECT file_size, device_id FROM files WHERE blake3_hash = ?1; --- If no row: - INSERT INTO files (blake3_hash, file_size, first_seen, updated_at, device_id) - VALUES (?1, ?2, ?3, ?3, ?4); - → Inserted --- If row exists and (file_size, device_id) match: - → Unchanged (skip the UPDATE entirely) --- If row exists and anything differs: - UPDATE files SET file_size = ?2, updated_at = ?3, device_id = ?4 - WHERE blake3_hash = ?1; - → Updated -COMMIT; -``` - -This is explicit, correct, and pairs naturally with the -`upsert_location` pattern below. - -### `upsert_location` - -App-level uniqueness on `(volume_id, relative_path, deleted_at IS -NULL)`. Before insert: - -1. `SELECT id FROM file_locations WHERE volume_id = ?1 AND - relative_path = ?2 AND deleted_at IS NULL` -2. If found, `blake3_hash` matches, AND `device_id` matches → - `Unchanged` (skip the UPDATE entirely; nothing changed). -3. If found and `blake3_hash` OR `device_id` differs → UPDATE - `blake3_hash`, `updated_at`, `device_id`; return `Updated`. -4. If not found → `INSERT`, return `Inserted`. - -This is 2 statements (SELECT + INSERT/UPDATE) wrapped in a -transaction. The app-level uniqueness check replaces the `UNIQUE` -constraint that CLAUDE.md forbids on mutable columns. - -### `list_file_locations` - -```sql -SELECT f.blake3_hash, f.file_size, fl.volume_id, fl.relative_path, - fl.status, fl.first_seen -FROM file_locations fl -JOIN files f ON f.blake3_hash = fl.blake3_hash -WHERE fl.deleted_at IS NULL - AND (?1 IS NULL OR fl.volume_id = ?1) -ORDER BY fl.relative_path -LIMIT ?2 -``` - -Maps rows into `Vec`. - ---- - -## CLI changes - -### `perima scan ` (no longer requires `--dry-run`) - -- Remove the `CoreError::Unsupported` guard from `cmd/scan.rs`. -- `--dry-run` becomes `#[arg(long)]` (optional, default false). -- When `dry_run` is false: - 1. Open DB via `db::connection::open(config.data_dir.join("perima.db"))`. - 2. Run migrations. - 3. For the scan, use a **well-known sentinel `VolumeId`** — - `VolumeId(Uuid::nil())` (all-zeros UUID). Every 1b scan writes - the same sentinel, so `file_locations` rows are dedup-safe - across runs. 1c replaces the sentinel by running - `UPDATE file_locations SET volume_id = WHERE volume_id - = '00000000-0000-0000-0000-000000000000'` after real volume - detection resolves the actual ID. Using a single well-known - sentinel (not a random UUID per scan) makes the 1c migration - trivial and prevents duplicate `file_locations` rows for the - same `(volume, path)` across multiple scans. - 4. After hashing, call `upsert_file` + `upsert_location` per file. - 5. Summary changes from `(dry-run; DB not yet wired)` to - `scanned files on volume ( new, existing, - errors)`. -- When `dry_run` is true: same behavior as 1a (walk + hash + print, - no DB). - -### `perima ls` - -Options: -- `--volume ` — filter to one volume (UUIDv7 hex). -- `--limit ` — default 100. -- `--json` — machine-readable output. - -Human-readable table output: - -``` -HASH SIZE VOLUME PATH -a1b2c3… 1.2 MB f0e9… photos/2024/IMG_001.jpg -``` - -JSON: `Vec` serde-serialized. - ---- - -## Test strategy - -### Unit tests (`crates/db/src/`) - -- `connection::open` on a tempfile → pragmas applied (query - `PRAGMA journal_mode` returns `wal`). -- `upsert_file` inserts a new row → `Inserted`; same row again - → `Unchanged`; same hash different size → `Updated`. -- `upsert_location` insert → `Inserted`; same path same hash - → `Unchanged`; same path different hash → `Updated`. -- `list_file_locations` with 3 rows → returns 3 in path order; - with volume filter → returns subset; with limit → truncates. -- Migration runs idempotently (open + migrate twice = no error). - -### Integration tests (`crates/cli/tests/`) - -- `scan_persists.rs`: create 3 fixture files, run `perima scan - ` (no `--dry-run`), then open the same DB with rusqlite - and assert 3 rows in `files` + 3 in `file_locations`. Run scan - again → 0 new rows (assert via summary line "0 new"). -- `ls_output.rs`: after a scan, run `perima ls --limit 10` and - assert 3 lines of output with correct format. Run `perima ls - --json` and deserialize the output back into - `Vec`. -- Update `scan_dry_run.rs`: remove the - `real_scan_refused_in_phase_1a` test (guard is gone); replace - with a test that `perima scan ` (no `--dry-run`) exits 0 - and produces the "scanned ... files" summary. - -### Existing tests - -All 27 phase-1a tests must remain green. The `--dry-run` path is -unchanged. - ---- - -## Dependencies - -Add to `crates/db/Cargo.toml`: - -```toml -[dependencies] -perima-core = { path = "../core" } -rusqlite.workspace = true -refinery.workspace = true -thiserror.workspace = true -tracing.workspace = true -uuid.workspace = true -chrono = { version = "0.4", features = ["serde"] } - -[dev-dependencies] -tempfile.workspace = true -``` - -`chrono` for ISO 8601 UTC timestamps (`Utc::now().to_rfc3339()`). -Simpler than hand-formatting; widely used. - -Add `chrono` to workspace deps: - -```toml -chrono = { version = "0.4", features = ["serde"] } -``` - -Add `perima-db` to `crates/cli/Cargo.toml`: - -```toml -perima-db = { path = "../db" } -``` - ---- - -## Exit criteria (phase 1b, autonomously verifiable) - -B1. `perima scan ` (no `--dry-run`) populates 3 rows in - `files` and 3 in `file_locations`. Verified by opening the DB - from the integration test with rusqlite and counting rows. - -B2. Re-running scan → 0 new rows (`Unchanged` for all). Verified - by the summary line containing "0 new". - -B3. `perima ls` output has 3 lines matching - `^[0-9a-f]{8}… $`. Verified via - the integration test. - -B4. `perima ls --json` output deserializes back into - `Vec`. - -B5. `perima scan --dry-run ` still works (unchanged path). - -B6. `PRAGMA journal_mode` returns `wal` on an opened DB. - -B7. Migration is idempotent (double-run, no error). - -B8. All 27 phase-1a tests still pass. - -B9. `just ci` green. - -B10. `cargo doc --workspace --no-deps` exit 0 — all new pub items - doc-commented. - -B11. WHY-comments: blake3_hash PK rationale in migration SQL, WAL - pragma rationale in `connection.rs`, app-level uniqueness - rationale in `file_repo.rs`. - ---- - -## Risks - -- **Sentinel VolumeId (`Uuid::nil`).** All 1b scans share the same - all-zeros volume ID. Multiple scans of the same directory are - dedup-safe (same sentinel + same relative_path = `Unchanged`). - Multiple scans of *different* directories merge into the same - volume — 1c's `UPDATE file_locations SET volume_id = - WHERE volume_id = '00000000-...'` must resolve by re-detecting - each file's actual volume. Acceptable: 1c is the next phase. -- **`refinery` + `rusqlite` version pairing.** Pinned in phase 0 - (`rusqlite 0.38` + `refinery 0.9`). No new risk. -- **`chrono` dependency size.** Adds ~50 KB. Acceptable. -- **Two-statement upsert concurrency.** The SELECT-then-INSERT/UPDATE - pattern is safe under SQLite's single-writer model (WAL allows - concurrent readers but only one writer holds the lock). No race. diff --git a/docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md b/docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md deleted file mode 100644 index cf833db..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-1c-volumes-manifest-design.md +++ /dev/null @@ -1,401 +0,0 @@ -# Phase 1c — Volumes, manifest, `perima volumes` - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** meta-plan, phase 1 entry (split 1a/1b/1c). -**Prior:** `phase-1b-complete` tag. - ---- - -## Goal - -Replace the sentinel `VolumeId(Uuid::nil())` with real volume -detection using `sysinfo`. Implement `VolumeRepository` in -`crates/db`. Write `.perima/manifest.db` at each volume root during -scan. Ship `perima volumes` command. Retroactively fix sentinel -rows from 1b scans. Tag `phase-1-complete` when done. - -## Non-goals - -- Manifest *recovery* from `.perima/manifest.db` (phase 9). -- File watching, EventBus (phase 3). -- Thumbnails, tags, FTS5 (phases 4-5). - ---- - -## Architecture - -### New modules - -``` -crates/fs/ -└── src/ - └── volumes.rs # volume detection via sysinfo - -crates/db/ -└── src/ - ├── volume_repo.rs # SqliteVolumeRepository impls VolumeRepository - └── manifest.rs # per-drive .perima/manifest.db writer -``` - -### Modified modules - -``` -crates/cli/ -└── src/ - ├── main.rs # add Volumes command, wire volume detection - ├── cmd/ - │ ├── mod.rs # add volumes module - │ ├── scan.rs # accept VolumeRepository, real volume detection - │ └── volumes.rs # new — perima volumes command -``` - ---- - -## Volume detection (`crates/fs/src/volumes.rs`) - -Uses `sysinfo::Disks` to enumerate mounted volumes, then matches -the scan root to a volume by longest mount-point prefix. - -```rust -/// Detect which volume contains `path` by finding the disk whose -/// mount point is the longest prefix of `path`. -pub fn detect_volume(path: &Path) -> Result - -pub struct DetectedVolume { - pub identifiers: VolumeIdentifiers, - pub mount_point: PathBuf, -} -``` - -### Drive-identifier priority chain - -When `VolumeRepository::find_or_create` looks for a matching row, -it uses this priority: - -1. **GPT partition GUID** — most reliable cross-platform ID. - Available on Linux via `blkid`, sysinfo may not expose it - directly. For v1, we extract what `sysinfo` provides. -2. **Filesystem UUID** (`fs_uuid`) — second most reliable. `sysinfo` - exposes this on most platforms. -3. **Volume label** — fallback. Can be user-changed; least stable. - -**Conflict resolution:** if GUID matches volume X but label matches -volume Y, **GUID wins** (higher priority). The match algorithm: -try GUID first; if no GUID match found, try fs_uuid; if none, -try label. First match wins. If no match at all, create new. - -On platforms where `sysinfo` returns empty identifiers (e.g., some -macOS APFS volumes), fall back to `(capacity_bytes, mount_point)` -as a heuristic — imperfect but better than creating a new volume -row each scan. - -**v1 honest assessment:** `sysinfo` 0.38 does NOT expose GPT GUID -or filesystem UUID on any platform. The priority chain (GUID → -fs_uuid → label) is a design-for-the-future skeleton. **Actual v1 -matching is label + capacity on all three platforms.** On Linux, -`blkid` could provide fs_uuid but requires root on many distros -(or reading `/run/blkid/blkid.tab` which may not exist); this is -**best-effort, not guaranteed**. The code structure supports -plugging in richer identifiers later without a refactor — that is -the value of the priority chain even when it falls through to -label+capacity today. - -### What `sysinfo` provides per disk - -- `name()` → device name (e.g., `/dev/sda1`). -- `mount_point()` → `&Path`. -- `file_system()` → `OsStr` (e.g., "ext4", "apfs"). -- `kind()` → `DiskKind` (HDD, SSD, Unknown). -- `is_removable()` → `bool`. -- `total_space()` → `u64`. -- `available_space()` → `u64`. - -`sysinfo` does NOT directly expose GPT GUID or filesystem UUID as -of 0.38. We set `gpt_partition_guid = None` and attempt to derive -`fs_uuid` from the device name on Linux (parse from `blkid` cache -at `/run/blkid/blkid.tab` or run `blkid -s UUID -o value `). -On macOS/Windows, `fs_uuid = None` for v1. This means v1 matching -is label + capacity on macOS/Windows — acceptable for a desktop- -only single-machine tool. - ---- - -## `SqliteVolumeRepository` (`crates/db/src/volume_repo.rs`) - -Implements `VolumeRepository`. Uses same `Mutex` pattern -as `SqliteFileRepository`. - -### `find_or_create` - -Priority-chain match: - -```sql --- Step 1: try GPT GUID (if provided) -SELECT volume_id FROM volumes -WHERE gpt_partition_guid = ?1 AND deleted_at IS NULL; - --- Step 2: try fs_uuid (if step 1 returned nothing) -SELECT volume_id FROM volumes -WHERE fs_uuid = ?1 AND deleted_at IS NULL; - --- Step 3: try label + capacity (if step 2 returned nothing) -SELECT volume_id FROM volumes -WHERE volume_label = ?1 AND capacity_bytes = ?2 AND deleted_at IS NULL; - --- Step 4: insert new -INSERT INTO volumes (volume_id, gpt_partition_guid, fs_uuid, - volume_label, capacity_bytes, is_removable, last_seen, - updated_at, device_id) -VALUES (...); -``` - -Each step is a SELECT; first non-empty result wins. If a match is -found, UPDATE `last_seen` + `updated_at`. If no match, INSERT. - -### `record_mount` - -App-level uniqueness on `(volume_id, machine_id, deleted_at IS -NULL)`. SELECT-then-INSERT/UPDATE like `upsert_location`. - -### `list` - -**Trait signature fix required:** the current trait at -`crates/core/src/ports/volume_repo.rs` defines `fn list(&self)` with -no `machine` parameter, but the SQL needs `machine_id` to filter -mounts. Update the trait to `fn list(&self, machine: DeviceId)`. - -```sql -SELECT v.*, vm.mount_path -FROM volumes v -LEFT JOIN volume_mounts vm - ON v.volume_id = vm.volume_id - AND vm.machine_id = ?1 - AND vm.deleted_at IS NULL -WHERE v.deleted_at IS NULL -ORDER BY v.volume_label; -``` - -Group rows by `volume_id` to build `VolumeRecord` with -`mounts_on_this_machine: Vec`. - ---- - -## Sentinel migration - -Phase 1b wrote `file_locations` rows with `volume_id = -'00000000-0000-0000-0000-000000000000'`. The sentinel migration -runs **per-file inside the walk loop**, not as a blanket UPDATE, -because a user who ran 1b scans on multiple directories may have -sentinel rows belonging to different volumes. - -Algorithm: during the scan persist loop, for each `(relative_path, -volume_id)` that was just upserted with the real volume ID, also -check if a sentinel row exists for the same `relative_path`: - -```sql -UPDATE file_locations -SET volume_id = ?1, updated_at = ?2, device_id = ?3 -WHERE volume_id = '00000000-0000-0000-0000-000000000000' - AND relative_path = ?4 - AND deleted_at IS NULL; -``` - -This scopes the fix to paths actually observed on the current -volume. Sentinel rows from other volumes' 1b scans remain -untouched until those volumes are scanned in 1c. After all volumes -have been scanned once in 1c, no sentinel rows should remain. - -The migration is idempotent (UPDATE WHERE already-real is a no-op) -and has zero cost on fresh installs (no sentinel rows exist). - ---- - -## Per-drive manifest (`crates/db/src/manifest.rs`) - -### Schema (`.perima/manifest.db`) - -Created at each volume's mount root. Uses a **separate** -`open_manifest(path)` that does NOT share the main DB connection. - -```sql -CREATE TABLE IF NOT EXISTS manifest_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS manifest_files ( - blake3_hash TEXT PRIMARY KEY, - file_size INTEGER NOT NULL, - relative_path TEXT NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL -); -``` - -`manifest_meta` seeded on creation with: `volume_id`, -`manifest_version` (= `"1"`), `created_at`. - -### Write strategy - -After the scan walk+hash+persist loop, a second pass writes -(or updates) the manifest. For each `HashedFile` that was -successfully persisted to the main DB: - -```rust -pub fn write_manifest( - volume_root: &Path, - volume_id: VolumeId, - files: &[HashedFile], -) -> Result<(), CoreError> -``` - -Uses `INSERT OR REPLACE` into `manifest_files` (the manifest -doesn't follow the main DB's "no UNIQUE on mutable" rule because -the manifest is local + non-CRDT; it's a recovery dump, not a -replicated table). - ---- - -## CLI changes - -### `perima scan ` (updated) - -- Detect volume via `detect_volume(canonical_root)`. -- `find_or_create` the volume in the DB. -- `record_mount` for this machine. -- Run sentinel migration (UPDATE sentinel rows). -- Walk + hash + persist (existing loop, now with real VolumeId). -- Write manifest to `/.perima/manifest.db`. -- Summary: `scanned files on volume (...)`. - -`scan::run` gains: -```rust -pub fn run( - scanner: &S, - hasher: &H, - file_repo: Option<&mut FR>, - volume_repo: Option<&mut VR>, - // ... -) -``` - -When `dry_run`, both repos are `None`. - -Note: the parameter count is now 8+. A `ScanContext` struct would -be the preferred refactor but is deferred to avoid scope creep in -1c. Acknowledged as tech debt for phase 2. - -### `perima volumes` - -No options. Output: - -``` -VOLUME ID LABEL REMOVABLE CAPACITY MOUNT PATHS -f0e9a1b2… BACKUP_SSD yes 2.0 TB /mnt/backup -``` - -Implementation is trivial — calls `VolumeRepository::list` and -prints a table. - ---- - -## Test strategy - -### Unit tests (`crates/db/src/volume_repo.rs`) - -- `find_or_create` inserts new → `VolumeId` returned. -- `find_or_create` matches on `fs_uuid` → same `VolumeId`. -- `find_or_create` matches on label+capacity → same `VolumeId`. -- `find_or_create` GUID match trumps label match (conflict test). -- `record_mount` insert + unchanged on repeat. -- `list` returns volumes with mount paths. - -### Unit tests (`crates/fs/src/volumes.rs`) - -- `detect_volume` on the current system returns a `DetectedVolume` - with non-zero capacity (smoke test — can't predict identifiers). -- `detect_volume` on a non-existent path returns `Err`. - -### Unit tests (`crates/db/src/manifest.rs`) - -- `write_manifest` creates `.perima/manifest.db` with correct - `manifest_meta` rows. -- `write_manifest` with 3 files → 3 `manifest_files` rows. -- Re-calling `write_manifest` with a changed file → row updated. - -### Integration tests (`crates/cli/tests/`) - -- `scan_with_volumes.rs`: scan a tmpdir, verify `volumes` table - has 1 row, `volume_mounts` has 1 row, `file_locations.volume_id` - is NOT the sentinel. -- `volumes_output.rs`: after scan, `perima volumes` shows 1 volume - with correct mount path. -- `manifest_created.rs`: after scan, `.perima/manifest.db` exists - at the tmpdir root with 3 `manifest_files` rows. - -### Existing tests - -All 42 phase-1b tests must remain green. - ---- - -## Dependencies - -Add to `crates/fs/Cargo.toml`: - -```toml -sysinfo.workspace = true -``` - -No new workspace deps needed (sysinfo already in workspace). - ---- - -## Exit criteria (phase 1c, autonomously verifiable) - -C1. After scan, `volumes` has 1 row; `volume_mounts` has 1 row - matching the current machine. Verified via integration test. - -C2. `perima volumes` output matches expected format (1 volume row). - -C3. `/.perima/manifest.db` exists with 3 rows in - `manifest_files` and volume identity in `manifest_meta`. - -C4. Volume-ID priority chain: GUID match trumps label match - (unit test with injected identifiers). - -C5. Sentinel migration: after 1c scan, zero rows have - `volume_id = '00000000-...'` in `file_locations`. - -C6. All 42 phase-1b tests still pass. - -C7. `just ci` green. - -C8. `cargo doc --workspace --no-deps` exit 0. - -C9. WHY-comments on: priority chain algorithm, sentinel migration, - manifest not following CRDT rules. - -C10. CI green on all 3 platforms after push. - -C11. `phase-1-complete` tag (note: not `phase-1c-complete`; this - completes all of phase 1 per the meta-plan). - ---- - -## Risks - -- **`sysinfo` platform variance.** GPT GUID and fs_uuid may not be - available on all platforms. Mitigation: fallback to label+capacity; - smoke test guards against silent `None` on CI runners. -- **Sentinel migration on large DBs.** Runs per-file inside the walk - loop (one UPDATE per relative_path). On a 100k-file scan this is - 100k small UPDATEs — fast under WAL. No concern. -- **`.perima/manifest.db` permissions.** On read-only volumes the - write will fail. Mitigation: catch the error, log a warning, - continue (the manifest is a convenience, not a hard requirement - for v1 correctness). -- **Manifest on Windows drive roots.** `C:\` → `.perima/manifest.db` - at `C:\.perima\manifest.db`. Possible admin-permission issue. - Mitigation: same — catch and warn. diff --git a/docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md b/docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md deleted file mode 100644 index 9d82f11..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-2-tauri-shell-design.md +++ /dev/null @@ -1,312 +0,0 @@ -# Phase 2 — Tauri shell (thin UI) - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** meta-plan phase 2. -**Prior:** `phase-1-complete` tag. - ---- - -## Goal - -Wrap the working indexing engine in a Tauri 2 + React desktop window. -Single page: flat table of indexed files (path, size, hash, volume, -status). Scan trigger button + native folder picker. No tags, no -search, no thumbnails — those are phases 4-5. The UI can be ugly; -the data layer proved itself in phase 1. - -## Non-goals - -- File watching / live updates (phase 3). -- Thumbnails, grid view, media metadata (phase 4). -- Tags, search, FTS5 (phases 5a/5b). -- HTTP API (phase 6). -- Docs site (phase 7). - ---- - -## Architecture - -``` -apps/desktop/ # Vite + React + Tailwind (Bun) -├── src/ -│ ├── App.tsx # root component -│ ├── components/ -│ │ ├── FileTable.tsx # table of FileLocationRecord -│ │ ├── ScanButton.tsx # folder picker + scan trigger -│ │ └── StatusBar.tsx # scan progress / last scan summary -│ ├── hooks/ -│ │ └── usePerima.ts # Tauri invoke wrappers -│ ├── types.ts # TS types mirroring Rust structs -│ └── main.tsx # React entry -├── index.html -├── package.json # Bun -├── tailwind.config.js -├── vite.config.ts -├── tsconfig.json -crates/desktop/ # Tauri backend (thin Rust wrapper) - # NO symlink from apps/desktop/src-tauri. - # Tauri CLI is invoked from workspace root: - # `cargo tauri dev -c crates/desktop/tauri.conf.json` - # This avoids symlink path-resolution issues. -├── Cargo.toml -├── tauri.conf.json -├── src/ -│ ├── lib.rs # Tauri plugin setup -│ └── commands.rs # #[tauri::command] fns -├── icons/ # app icons (Tauri defaults) -└── capabilities/ # Tauri v2 permissions -``` - -### IPC via `tauri-specta` - -`tauri-specta` generates TypeScript bindings from `#[tauri::command]` -functions at build time. The TS side calls `invoke("scan", { path })` -and gets typed responses. No manual JSON marshaling. - -### Commands exposed to the frontend - -```rust -#[tauri::command] -#[specta::specta] -async fn scan(path: String, dry_run: bool) -> Result; - -#[tauri::command] -#[specta::specta] -async fn list_files(limit: u32, volume: Option) -> Result, String>; - -#[tauri::command] -#[specta::specta] -async fn list_volumes() -> Result, String>; -``` - -Each command constructs its own DB connection (same `open_and_migrate` -pattern from phase 1b) inside a `tauri::State` that holds -the resolved `Config`. The commands are thin wrappers calling the -same core logic the CLI uses. - -### `AppState` - -```rust -pub struct AppState { - pub config: Config, - pub device_id: DeviceId, -} -``` - -Constructed once during Tauri setup from the same `Config::resolve` -the CLI uses. DB connections are opened per-command (cheap under WAL; -avoids lifetime/Send issues with Tauri's async command system). - ---- - -## Frontend - -### Tech stack - -- **Bun** for install/run/build (per CLAUDE.md). -- **Vite** for dev server + HMR. -- **React 19** (latest stable). -- **Tailwind CSS 4** for styling. -- **TypeScript** strict mode. -- `eslint-plugin-tsdoc` for TSDoc validation. - -### UI layout (single page) - -``` -┌──────────────────────────────────────────┐ -│ perima [Scan Folder] │ -├──────────────────────────────────────────┤ -│ HASH SIZE VOLUME PATH │ -│ a1b2c3… 1.2MB f0e9… photos/a.jpg │ -│ d4e5f6… 3.4MB f0e9… photos/b.jpg │ -│ ... │ -├──────────────────────────────────────────┤ -│ Status: scanned 42 files (3 new) │ -└──────────────────────────────────────────┘ -``` - -- **ScanButton**: opens native folder picker via Tauri's `dialog` - plugin, then invokes `scan(path, false)`. While scanning, the - button is disabled and the StatusBar shows progress. -- **FileTable**: calls `list_files(100, null)` on mount and after - each scan. Renders a `
` with sortable columns (client-side - sort only — no server-side pagination in phase 2). -- **StatusBar**: shows the result of the last scan or "No scans yet." - -### Type safety - -`tauri-specta` generates `bindings.ts` with typed `invoke` wrappers. -The frontend imports these instead of calling raw `invoke`. - ---- - -## Tauri configuration - -### `tauri.conf.json` key settings - -```json -{ - "productName": "perima", - "identifier": "dev.perima.desktop", - "build": { - "devUrl": "http://localhost:5173", - "frontendDist": "../../apps/desktop/dist" - }, - "app": { - "windows": [{ - "title": "perima", - "width": 1024, - "height": 768 - }] - }, - "plugins": { - "dialog": { "open": true } - } -} -``` - -### Capabilities - -Tauri v2 uses a capability system. Create -`crates/desktop/capabilities/default.json`: - -```json -{ - "identifier": "default", - "windows": ["main"], - "permissions": [ - "core:default", - "dialog:default" - ] -} -``` - ---- - -## Testing strategy - -### Headless Tauri test (Rust side) - -`tauri::test` utilities allow invoking commands without a window. -Test in `crates/desktop/tests/`: - -- `commands_test.rs`: invoke `scan` with a tmpdir fixture → assert - `ScanResult` contains expected counts. Invoke `list_files` → assert - 3 records. Invoke `list_volumes` → assert ≥ 1 volume. - -### React component tests (TS side) - -`vitest` + `jsdom` for component rendering: - -- `FileTable.test.tsx`: render with mock data → assert 3 rows. -- `ScanButton.test.tsx`: render → assert button present; mock - invoke → assert calls scan command. -- `StatusBar.test.tsx`: render with "scanned 3 files" → assert text. - -### Integration - -No e2e (Playwright) in phase 2 — the meta-plan defers visual -verification. Headless command tests + component snapshot tests -cover the IPC contract. - ---- - -## Dependencies - -### Rust (workspace) - -Already pinned in phase 0: `tauri = "2"`, `tauri-specta = "=2.0.0-rc.24"`. - -Add to `crates/desktop/Cargo.toml`: - -```toml -[dependencies] -perima-core = { path = "../core" } -perima-db = { path = "../db" } -perima-fs = { path = "../fs" } -perima-hash = { path = "../hash" } -tauri.workspace = true -tauri-specta.workspace = true -specta = "=2.0.0-rc.24" # matches tauri-specta 2.0.0-rc.24 -specta-typescript = "0.0.11" # latest as of 2026-04-16 -serde.workspace = true -serde_json.workspace = true -tracing.workspace = true - -[build-dependencies] -tauri-build = "2" -specta-typescript = "0.0.9" -``` - -### Frontend (Bun) - -```json -{ - "dependencies": { - "react": "^19", - "react-dom": "^19", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2" - }, - "devDependencies": { - "vite": "^6", - "@vitejs/plugin-react": "^4", - "typescript": "^5", - "tailwindcss": "^4", - "@tailwindcss/vite": "^4", - "vitest": "^3", - "jsdom": "^26", - "@testing-library/react": "^16", - "eslint": "^9", - "eslint-plugin-tsdoc": "^0.4" - } -} -``` - ---- - -## Sub-phase split - -Phase 2 is split into 2a (Rust Tauri crate + headless tests) and 2b -(React frontend + component tests). Each gets its own plan. - -### Phase 2a exit criteria - -P2a-1. `cargo build -p perima-desktop` exits 0. -P2a-2. Headless Tauri command tests pass: scan, list_files, list_volumes - return expected data against a tmpdir fixture. -P2a-3. `cargo clippy -p perima-desktop -- -D warnings` exits 0. -P2a-4. `just ci` green (existing 63 tests + new desktop command tests). -P2a-5. `tauri.conf.json` present with correct paths. -P2a-6. `specta` version resolved and pinned correctly (matches - `tauri-specta` transitive dep). - -### Phase 2b exit criteria - -P2b-1. `bun install && bun run build` in `apps/desktop/` exits 0. -P2b-2. `vitest run` in `apps/desktop/` exits 0 — component tests pass. -P2b-3. TSDoc validation: `bun run lint` exits 0. -P2b-4. `just ci` green (including frontend build + test). -P2b-5. `phase-2-complete` tag pushed after CI green. - ---- - -## Risks - -- **Tauri v2 + specta RC churn.** `tauri-specta = "=2.0.0-rc.24"` is - an exact pin for this reason. If it breaks on a new Tauri patch, - we hold the pin until specta ships stable. -- **Bun + Vite + Tauri dev-server.** Tauri expects `devUrl` at - localhost:5173; Bun + Vite serve there by default. If port - conflicts occur, Vite auto-increments; Tauri won't find it. - Mitigation: pin port in `vite.config.ts`. -- **UI correctness unverifiable autonomously.** Per the meta-plan: - "UI phases ship with headless IPC tests + component snapshot tests - only. Visual polish is explicit non-goal for v1." -- **`tauri::test` maturity.** Tauri v2's test utilities are newer than - v1's. If headless command invocation doesn't work, fall back to - integration tests that launch the binary and communicate via - stdio/IPC. -- **Node.js 20 deprecation.** GitHub Actions warns about this for - `actions/checkout@v4`. Tracked in DECISIONS.md; not blocking. diff --git a/docs/superpowers/specs/2026-04-16-phase-2b-react-frontend-design.md b/docs/superpowers/specs/2026-04-16-phase-2b-react-frontend-design.md deleted file mode 100644 index df6e370..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-2b-react-frontend-design.md +++ /dev/null @@ -1,330 +0,0 @@ -# Phase 2b — React frontend for Tauri shell - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** phase 2 spec (split 2a/2b). -**Prior:** phase 2a committed (Tauri backend crate, 67 tests, CI green). - ---- - -## Goal - -Ship the React + Vite + Tailwind frontend in `apps/desktop/`. Single -page with a file table, scan button with native folder picker, and -status bar. Replace the placeholder `index.html` with a real Vite -build. `vitest` component tests prove the rendering contract. - -## Non-goals - -- Sorting, pagination, or filtering beyond what phase 1's `perima ls - --limit` provides (server-side pagination is phase 5b territory). -- Thumbnails or grid view (phase 4). -- Tags, search (phases 5a/5b). -- Styling beyond "functional and readable" — visual polish is - explicitly a post-v1 concern per the meta-plan. - ---- - -## Tech stack - -- **Bun** for install/run/build/test (per CLAUDE.md — never npm/pnpm/yarn). -- **Vite 6** for dev server + HMR + production build. -- **React 19** (latest stable). -- **Tailwind CSS 4** via `@tailwindcss/vite` plugin. -- **TypeScript 5** strict mode. -- **vitest 3** + `jsdom` + `@testing-library/react` for component tests. -- **`@tauri-apps/api` v2** for `invoke()`. -- **`@tauri-apps/plugin-dialog` v2** for native folder picker. -- No zod (specta-generated types are the source of truth; runtime - validation is redundant at the Tauri IPC boundary). -- **`neverthrow`** for typed `Result` in TypeScript. `api.ts` - returns `ResultAsync` instead of raw `Promise`. - Components use `.match()` instead of try/catch. Establishes the - pattern early even though there are only 3 commands now. -- **ESLint 9** flat config + `eslint-plugin-tsdoc` for doc validation. - ---- - -## File structure - -``` -apps/desktop/ -├── index.html # Vite entry -├── package.json # Bun -├── bunfig.toml # Bun config (if needed) -├── vite.config.ts # Vite + React + Tailwind -├── tsconfig.json # strict TS -├── eslint.config.js # flat config + tsdoc -├── src/ -│ ├── main.tsx # React 19 createRoot -│ ├── App.tsx # layout shell -│ ├── App.css # Tailwind @import -│ ├── types.ts # TS types (ScanResult, FileEntry, VolumeEntry) -│ ├── api.ts # Tauri invoke wrappers -│ ├── components/ -│ │ ├── FileTable.tsx # table of FileEntry -│ │ ├── ScanButton.tsx # folder picker + scan trigger -│ │ └── StatusBar.tsx # last scan summary -│ └── __tests__/ -│ ├── FileTable.test.tsx -│ ├── ScanButton.test.tsx -│ └── StatusBar.test.tsx -└── dist/ # Vite build output (gitignored) -``` - -### `types.ts` - -Mirror the Rust wrapper types from `commands.rs`: - -```typescript -/** Result of a scan command. */ -export interface ScanResult { - total: number; - new: number; - existing: number; - errors: number; -} - -/** A file location record (joined files + file_locations row). */ -export interface FileEntry { - hash: string; // 64-char lowercase hex - size: number; // bytes - volume_id: string; // UUID - relative_path: string; - status: string; // "active" | "missing" | "moved" - first_seen: string; // ISO 8601 -} - -/** A volume record. */ -export interface VolumeEntry { - id: string; // UUID - label: string | null; - capacity_bytes: number; - is_removable: boolean; - mounts_on_this_machine: string[]; - last_seen: string; // ISO 8601 -} -``` - -These types could be auto-generated by `tauri-specta` in debug -builds (the Rust side already wires `specta_builder.export()`). -For v1, hand-written types are acceptable since there are only 3 -interfaces. If they drift, the TS compiler will catch the mismatch -at build time via the `invoke` return types. - -### `api.ts` - -Thin wrappers around `invoke`: - -```typescript -import { invoke } from "@tauri-apps/api/core"; -import type { ScanResult, FileEntry, VolumeEntry } from "./types"; - -import { ResultAsync, errAsync, okAsync } from "neverthrow"; - -function fromInvoke(cmd: string, args: Record): ResultAsync { - return ResultAsync.fromPromise( - invoke(cmd, args), - (e) => (e instanceof Error ? e.message : String(e)), - ); -} - -export function scan(path: string, dryRun: boolean): ResultAsync { - return fromInvoke("scan", { path, dryRun }); -} - -export function listFiles(limit: number, volume?: string): ResultAsync { - return fromInvoke("list_files", { limit, volume: volume ?? null }); -} - -export function listVolumes(): ResultAsync { - return fromInvoke("list_volumes", {}); -} -``` - ---- - -## Components - -### `App.tsx` - -Layout shell: -- Header with app name + ``. -- `` as the main content. -- `` at the bottom. -- State: `files: FileEntry[]`, `scanResult: ScanResult | null`, - `scanning: boolean`, `error: string | null`. -- On mount: call `listFiles(100)` to populate the table. -- On scan complete: refresh the table. - -### `FileTable.tsx` - -Props: `{ files: FileEntry[], loading: boolean }`. -- Renders a `
` with columns: HASH (truncated to 8 chars), - SIZE (human-readable), VOLUME (truncated UUID), PATH, STATUS. -- When `loading` is true, shows a spinner or "Loading..." row. -- When `files` is empty and not loading, shows "No files indexed yet." -- Client-side sorting by clicking column headers (state: `sortBy`, - `sortDir`). Simple `Array.sort` — no virtual scrolling in phase 2. - -### `ScanButton.tsx` - -Props: `{ onScanComplete: (result: ScanResult) => void, scanning: boolean, onScanStart: () => void }`. -- Renders a button "Scan Folder". -- On click: opens native folder picker via `@tauri-apps/plugin-dialog`'s `open({ directory: true })`. -- If user picks a folder: calls `onScanStart()`, then `api.scan(path, false)`. -- On success: calls `onScanComplete(result)`. -- While scanning: button is disabled, text changes to "Scanning...". -- On error: displays error string (simple `window.alert` for v1; - toast system is post-v1). - -### `StatusBar.tsx` - -Props: `{ scanResult: ScanResult | null, error: string | null }`. -- Shows: "scanned {total} files ({new} new, {existing} existing, - {errors} errors)" when scanResult is present. -- Shows the error string in red when error is present. -- Shows "No scans yet" when both are null. - ---- - -## Styling - -Tailwind 4 via `@tailwindcss/vite` plugin. Minimal: -- Dark background (`bg-gray-900`), light text (`text-gray-100`). -- Table with alternating row colors. -- Button with hover/disabled states. -- Monospace font for hash/path columns. -- No design system, no component library. Raw Tailwind utilities. - ---- - -## Testing - -### vitest component tests - -Each component gets a test file in `src/__tests__/`. Use -`@testing-library/react` for rendering + queries. Mock `api.ts` -calls via `vi.mock()`. - -- **`FileTable.test.tsx`**: render with 3 mock `FileEntry` items → - assert 3 `` data rows + 1 header row. Render with empty - array → assert "No files indexed yet" text. Render with - `loading=true` → assert loading indicator. - -- **`ScanButton.test.tsx`**: render → assert button text "Scan - Folder". Mock `open` from `@tauri-apps/plugin-dialog` to return - a path. Mock `api.scan` to return a `ScanResult`. Click the - button → assert `onScanStart` called → assert `api.scan` called - with the path → assert `onScanComplete` called with the result. - -- **`StatusBar.test.tsx`**: render with `scanResult` → assert - summary text. Render with `error` → assert error text in red. - Render with both null → assert "No scans yet". - -### Build verification - -- `bun run build` produces `dist/index.html` + JS/CSS bundles. -- `bun run lint` (eslint) exits 0. - -### Lockfile - -The first task must run `bun install` to generate `bun.lock`, then -commit it. CI uses `bun install --frozen-lockfile` which requires -the lockfile to exist. - -### IPC argument casing - -Tauri v2's `invoke` sends JSON with the keys exactly as written in -TypeScript. The Rust `#[tauri::command]` functions use `serde` for -deserialization — by default serde expects snake_case. Tauri v2 adds -`#[serde(rename_all = "camelCase")]` on the generated command argument -struct, so `dryRun` in TS maps to `dry_run` in Rust automatically. -If this doesn't work (RC churn), the `api.ts` calls must send -snake_case keys: `{ path, dry_run: dryRun }`. - ---- - -## Vite configuration - -```typescript -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; - -export default defineConfig({ - plugins: [react(), tailwindcss()], - server: { - port: 5173, // pinned to match tauri.conf.json devUrl - strictPort: true, // fail if port is taken, don't auto-increment - }, - build: { - outDir: "dist", - emptyDirOnBuild: true, - }, -}); -``` - ---- - -## CI integration - -Update `justfile` to include frontend build + test: - -```just -build-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run build - -test-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run test - -lint-frontend: - cd apps/desktop && bun install --frozen-lockfile && bun run lint -``` - -Update `ci` target to include frontend gates: - -```just -ci: fmt-check clippy test doctest docs-coverage build-frontend test-frontend lint-frontend -``` - -CI workflow needs `bun` installed on all runners. Add to `ci.yml`: - -```yaml -- uses: oven-sh/setup-bun@v2 - with: - bun-version: latest -``` - ---- - -## Exit criteria (phase 2b, autonomously verifiable) - -P2b-1. `bun install && bun run build` in `apps/desktop/` exits 0. -P2b-2. `bun run test` in `apps/desktop/` exits 0 (3+ component - tests pass via vitest). -P2b-3. `bun run lint` exits 0 (eslint + tsdoc). -P2b-4. `just ci` green (including frontend build + test + lint). -P2b-5. CI green on all 3 platforms. -P2b-6. `phase-2-complete` tag pushed. - ---- - -## Risks - -- **Bun + Tauri dev server interaction.** `bun run dev` (Vite) + - `cargo tauri dev` must coordinate. Tauri starts the dev server - itself via `beforeDevCommand` — set to `bun run dev` in - `tauri.conf.json`. If Bun's dev server doesn't cleanly exit on - SIGTERM (known issue), orphan processes may hold the port. - Mitigation: pin port + `strictPort: true` in vite config. -- **`@tauri-apps/plugin-dialog` in tests.** The dialog plugin - requires the Tauri runtime; in vitest/jsdom it's unavailable. - Mitigation: mock the entire `@tauri-apps/plugin-dialog` module - in tests. -- **`@tauri-apps/api` invoke in tests.** Same — mock `invoke` - globally in vitest setup. -- **Tailwind 4 + Vite plugin maturity.** Tailwind 4 is newer than - 3; the `@tailwindcss/vite` plugin may have rough edges. - Mitigation: fall back to `tailwindcss` PostCSS plugin if the - Vite plugin breaks. diff --git a/docs/superpowers/specs/2026-04-16-phase-3-watching-design.md b/docs/superpowers/specs/2026-04-16-phase-3-watching-design.md deleted file mode 100644 index 359ae2f..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-3-watching-design.md +++ /dev/null @@ -1,318 +0,0 @@ -# Phase 3 — Watching + incremental updates - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** meta-plan phase 3. -**Prior:** `phase-2-complete` tag. - ---- - -## Goal - -Watch managed directories for filesystem changes and update -`file_locations.status` (active/missing/moved) in real time. Ship -`perima watch ` in the CLI and emit Tauri events so the -desktop UI reflects changes live. - -This phase introduces: -- `tokio` as the async runtime (needed for `notify-debouncer-full`). -- `EventBus` trait in core + concrete adapters. -- `file-id` crate for rename stitching. -- `Stale` variant added to `LocationStatus` enum. -- **`Asset` type-state is NOT introduced** — still no - consumer that needs compile-time state transitions. The watcher's - `FileEvent` enum carries all needed information. Revisit when a - real consumer appears (phase 9 recovery?). - -## Non-goals - -- Thumbnails, EXIF metadata (phase 4). -- Tags, search, FTS5 (phases 5a/5b). -- HTTP API (phase 6). -- Re-hashing moved/modified files (tracked as "missing" + "active" - at new path; full re-hash is phase 9 perf work). - ---- - -## Architecture - -### New/modified crates - -``` -crates/core/src/ -├── events.rs # new — EventBus trait + FileEvent enum -├── types.rs # modify — add Stale to LocationStatus - -crates/fs/src/ -├── watcher.rs # new — DebouncedWatcher wrapping notify - -crates/db/src/ -├── file_repo.rs # modify — add update_status method - -crates/cli/src/ -├── cmd/watch.rs # new — perima watch -├── main.rs # modify — add Watch command - -crates/desktop/src/ -├── commands.rs # modify — add start_watch/stop_watch -├── events.rs # new — Tauri event emitter (EventBus impl) -``` - -### EventBus trait - -```rust -// crates/core/src/events.rs - -/// A filesystem event that the watcher detected. -#[derive(Clone, Debug, Serialize)] -pub enum FileEvent { - Created { path: MediaPath, volume: VolumeId }, - Modified { path: MediaPath, volume: VolumeId }, - Deleted { path: MediaPath, volume: VolumeId }, - Renamed { from: MediaPath, to: MediaPath, volume: VolumeId }, -} - -/// Consumers of filesystem events. -pub trait EventBus: Send + Sync { - /// Emit an event. Implementations decide how to deliver it - /// (DB update, Tauri event, log, etc.). - /// - /// Returns `Err` if the handler fails (e.g., DB write error). - /// The composite bus logs errors from individual handlers but - /// does not abort — remaining handlers still fire. - fn emit(&self, event: &FileEvent) -> Result<(), CoreError>; -} -``` - -Multiple `EventBus` implementations can be composed: -- **`DbEventHandler`** — updates `file_locations.status` on each event. -- **`TauriEventEmitter`** — emits Tauri app events for the frontend. -- **`LogEventHandler`** — logs via `tracing::info!` (always on). -- **`CompositeEventBus`** — wraps `Vec>`, fans out. - -### Watcher (`crates/fs/src/watcher.rs`) - -Uses `notify-debouncer-full` (handles debouncing, renames via -`file-id`, cross-platform quirks). The debouncer emits -`DebouncedEvent`s on a channel; our adapter maps them to -`FileEvent`s and calls `EventBus::emit`. - -```rust -pub struct DebouncedWatcher { - // Holds the debouncer handle; dropping it stops watching. - _debouncer: Debouncer, -} - -impl DebouncedWatcher { - /// Start watching `paths` recursively. Events are emitted to - /// `bus` via a background tokio task. - pub fn start( - paths: &[PathBuf], - volume_root: &Path, - bus: Arc, - cancel: CancellationToken, - ) -> Result; -} -``` - -The watcher runs on a tokio background task. Phase 3 **migrates the -CLI's cancellation from `AtomicBool` (`signals.rs`) to -`CancellationToken` from `tokio-util`**. The `ctrlc` handler now -calls `token.cancel()` instead of flipping an `AtomicBool`. Both -`scan` and `watch` share the same `CancellationToken`. The desktop -backend also migrates its cancellation stub to use the same token. -This unifies the two cancellation mechanisms that would otherwise -coexist and cause bugs when scan + watch need to share a shutdown. - -### Tokio introduction - -Phase 3 is the first phase that needs async: -- `notify-debouncer-full`'s channel is `std::sync::mpsc`, but we - need a tokio task to poll it without blocking the main thread. -- Tauri v2 commands already run on tokio under the hood. -- The CLI's `perima watch` needs to run indefinitely until Ctrl-C. - -**Approach:** introduce `tokio` as the runtime in both CLI and -desktop. The CLI's `main()` becomes `#[tokio::main]`. Existing -sync commands (`scan`, `ls`, `volumes`) remain synchronous — -`tokio::task::spawn_blocking` wraps them if needed, but since -they're already sync and called from the main task, they just run -directly. - -### Status transitions - -When an event arrives: - -| Event | DB action | -|-------|-----------| -| Created | Hash the new file → `upsert_file` + `upsert_location` (status = active) | -| Modified | `UPDATE file_locations SET status = 'stale'` — hash is outdated, re-scan re-hashes | -| Deleted | `UPDATE file_locations SET status = 'missing'` WHERE path matches | -| Renamed | `UPDATE file_locations SET relative_path = , status = 'active'` WHERE old path matches | - -**WHY Modified → stale (not missing):** a modified file's BLAKE3 -hash is now outdated, but the file is still present at the same -path. `Missing` means "file not found at this path" — using it for -modified files would corrupt the semantic meaning. `Stale` means -"hash outdated, re-scan needed." Rather than re-hashing -synchronously in the event handler (slow for large files, blocks -the watcher), we set `stale` so the next `perima scan` picks it up -and re-hashes. Trade-off: accuracy deferred by one scan cycle in -exchange for watcher responsiveness. - -`LocationStatus` enum gains a `Stale` variant. The `status` column -in `file_locations` is `TEXT`, so adding `"stale"` as a value is a -data-level change (no DDL), respecting the schema freeze. - -### Frontend live updates - -The Tauri backend starts watching when a scan completes (or when -the user explicitly clicks "Watch"). Events are emitted to the -frontend via `app_handle.emit("file-event", &event)`. The React -frontend subscribes via `listen("file-event", callback)` and -updates the file table reactively. - -Simple approach for v1: on each event, re-fetch the entire file -list via `listFiles()`. This is a full refresh, not an incremental -patch — acceptable for up to ~10k files. Incremental patching is -phase 9 perf work. - -**Frontend debounce:** batch events within a 300ms window before -calling `listFiles()`. Without debouncing, rapid filesystem -operations (extracting an archive) would fire dozens of full -SELECT+JOIN queries per second. - ---- - -## CLI: `perima watch ` - -``` -perima watch -``` - -Options: -- `--data-dir ` — override DB location. -- `-v / -vv` — tracing verbosity. - -Behavior: -1. Resolve config, open DB, detect volume. -2. Start `DebouncedWatcher` on ``. -3. Log events via `tracing::info!`. -4. On each event, update DB status. -5. Run until Ctrl-C (exit 130). -6. On Ctrl-C: drop the watcher (stops notify), print summary - `"watched : events processed"`. - ---- - -## Testing strategy - -### Unit tests (`crates/fs/src/watcher.rs`) - -- `watcher_detects_create` — create a file in a watched tmpdir → - assert `FileEvent::Created` emitted to a mock `EventBus`. -- `watcher_detects_delete` — delete a file → `FileEvent::Deleted`. -- `watcher_detects_rename` — rename a file → `FileEvent::Renamed` - with correct `from`/`to` paths. - -These tests use real filesystem operations on `tempfile::tempdir()` -with a short debounce timeout (100ms). They may be flaky on very -slow CI — gate behind a retry count or `#[ignore]` + manual run. - -### Unit tests (`crates/db/src/file_repo.rs`) - -- `update_status_to_missing` — set a location status to "missing". -- `update_status_to_moved` — update relative_path + status. - -### Integration test (`crates/cli/tests/watch_integration.rs`) - -- Start `perima watch ` as a child process. -- Create a file in the tmpdir. -- Wait 2 seconds. -- Kill the child (SIGTERM). -- Assert stderr mentions the created file event. - -This test is `#[cfg(unix)]` only (signal handling differs on Windows). - -### Desktop test - -- Add a headless test that invokes `start_watch`, creates a file, - waits, invokes `stop_watch`, then `list_files` → assert the new - file appears. - ---- - -## Dependencies to add - -```toml -# workspace Cargo.toml -tokio-util = { version = "0.7", features = ["rt"] } -notify-debouncer-full = "0.7" # already in workspace -file-id = "0.2" # already in workspace -``` - -`tokio` is already in the workspace. `notify` and -`notify-debouncer-full` are already pinned. `file-id` is already -pinned. - -Add `tokio-util` to workspace deps (for `CancellationToken`). - ---- - -## Sub-phase split - -Phase 3 is one-plan-sized IF the scope is: -- watcher module + EventBus trait + DB status updates + CLI watch - command + 1 integration test. -- Desktop Tauri events + frontend live refresh are a thin layer - on top. - -**Split confirmed by reviewer:** 3a (EventBus + watcher + DB status -+ CLI watch + tokio + cancellation migration) and 3b (Tauri events -+ frontend live refresh with debounced re-fetch). Each gets its own -plan. `phase-3-complete` tag lands at the end of 3b. - ---- - -## Exit criteria (autonomously verifiable) - -P3-1. `perima watch ` runs, detects file creation, prints - event to stderr, updates DB status. Verified via integration - test. -P3-2. `perima watch` exits 130 on Ctrl-C with a summary line. -P3-3. After deleting a file while watching, `perima ls` shows - `status = missing` for that file. -P3-4. After renaming a file while watching, `perima ls` shows the - new path with `status = active`. -P3-5. Tauri frontend receives `file-event` events and refreshes - the file table (tested via headless command test). -P3-6. Integration test with 10+ mutations all reflected in DB - within 2 seconds (p95). -P3-7. All 76 phase-2 tests still pass. -P3-8. `just ci` green. -P3-9. `phase-3-complete` tag pushed after CI green. - ---- - -## Risks - -- **Watcher test flakiness.** Filesystem events are inherently - timing-dependent. Mitigation: generous timeouts (2s debounce - window), retry logic in tests, `#[cfg(unix)]` gate for signal - tests. -- **`notify` cross-platform quirks.** macOS uses `FSEvents` (batch - events), Linux uses `inotify` (per-file events), Windows uses - `ReadDirectoryChangesW`. The debouncer normalizes these, but - edge cases (rapid renames, very deep trees) may differ. - Mitigation: test on all 3 CI platforms; accept minor behavioral - differences. -- **Tokio in CLI.** Switching `main()` from sync to - `#[tokio::main]` could break the existing sync scan flow if any - code accidentally holds a `MutexGuard` across an `.await`. - Mitigation: existing scan remains fully synchronous; only `watch` - uses async. The `#[tokio::main]` macro creates a multi-thread - runtime, but sync commands complete on the main task without - yielding. -- **Modified → stale deferred re-hash.** Users see "stale" status - for files they've edited until the next scan. Acceptable for v1; - document in `perima watch --help`. diff --git a/docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md b/docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md deleted file mode 100644 index 5516d55..0000000 --- a/docs/superpowers/specs/2026-04-16-phase-3b-tauri-events-frontend-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# Phase 3b — Tauri events + frontend live refresh - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** phase 3 spec (split 3a/3b). -**Prior:** phase 3a committed (watcher + CLI watch + CancellationToken + DB status methods). - ---- - -## Goal - -Wire the watcher into the Tauri desktop app so the frontend file -table updates live when files change in a watched directory. Emit -Tauri events from the backend; subscribe from the React frontend; -debounce frontend re-fetches. - -## Non-goals - -- Starting/stopping the watcher via UI buttons (auto-start on scan - completion for v1). -- Incremental list patching (full re-fetch with 300ms debounce is - fine for v1). -- Pausing events / filtering by volume (all events fire; frontend - decides what to do). - ---- - -## Architecture - -### New module: `crates/desktop/src/events.rs` - -Implements a Tauri-specific `EventBus` that emits events to the -frontend via `app_handle.emit`: - -```rust -pub struct TauriEventEmitter { - app_handle: AppHandle, -} - -impl TauriEventEmitter { - pub fn new(app_handle: AppHandle) -> Self { ... } -} - -impl EventBus for TauriEventEmitter { - fn emit(&self, event: &FileEvent) -> Result<(), CoreError> { - // emit on the "file-event" channel with a specta-typed payload. - self.app_handle.emit("file-event", event) - .map_err(|e| CoreError::Internal(format!("tauri emit: {e}"))) - } -} -``` - -`FileEvent` already derives `Serialize`. **Decision:** keep -`perima-core` framework-free — do NOT add specta to core. Instead, -use a desktop-crate-local wrapper `FileEventPayload` that derives -`specta::Type` + `Serialize` (see "Dependencies" section below for -the wrapper definition). - -### New Tauri commands - -```rust -#[tauri::command] -#[specta::specta] -async fn start_watch( - path: String, - app_handle: AppHandle, - state: tauri::State<'_, AppState>, - watcher_state: tauri::State<'_, WatcherState>, -) -> Result<(), String>; - -#[tauri::command] -#[specta::specta] -async fn stop_watch( - watcher_state: tauri::State<'_, WatcherState>, -) -> Result<(), String>; - -#[tauri::command] -#[specta::specta] -async fn is_watching( - watcher_state: tauri::State<'_, WatcherState>, -) -> Result; -``` - -### `WatcherState` - -Holds the active watcher (if any): - -```rust -pub struct WatcherState { - // WHY tokio::sync::Mutex: Tauri v2 async commands run on tokio; - // std::sync::Mutex held across .await is a clippy warning and a - // real footgun. tokio::sync::Mutex is await-safe. - inner: tokio::sync::Mutex>, - cancel: tokio::sync::Mutex>, -} -``` - -When `start_watch` is called: cancel any existing watcher, create a -new `CancellationToken`, create `CompositeEventBus(DbEventHandler + -TauriEventEmitter + LogEventHandler)`, start `DebouncedWatcher`, -store it in `inner`. - -When `stop_watch` is called: `cancel.cancel()` + drop the watcher. - -### Frontend subscription - -`apps/desktop/src/api.ts` gains: - -```typescript -export function startWatch(path: string): ResultAsync { ... } -export function stopWatch(): ResultAsync { ... } -export function isWatching(): ResultAsync { ... } - -export function subscribeToFileEvents( - callback: (event: FileEvent) => void, -): UnsubscribeFn { - // wraps listen("file-event", callback) from @tauri-apps/api/event -} -``` - -### `App.tsx` changes - -- After a successful scan, automatically call `startWatch(path)`. -- Subscribe to `file-event` on mount. On each event: - - Debounce via a 300ms timer (clear + reset). - - When the timer fires, call `listFiles(100)` and update the - table. -- Add a visual indicator in the status bar: "👁 watching " - when `isWatching()` is true. -- On unmount: unsubscribe + `stopWatch()`. - ---- - -## Dependencies - -No new deps. `specta::Type` already available on core (added -transitively through desktop's needs in phase 2a). - -Wait — core currently does NOT depend on specta. Adding `specta` to -core breaks the "zero framework deps" rule. - -**Revised decision:** wrap `FileEvent` in a desktop-crate-local -wrapper type that derives `specta::Type`: - -```rust -// crates/desktop/src/events.rs -#[derive(Serialize, specta::Type)] -#[serde(tag = "type")] -pub enum FileEventPayload { - Created { path: String, volume: String }, - Modified { path: String, volume: String }, - Deleted { path: String, volume: String }, - Renamed { from: String, to: String, volume: String }, -} - -impl From<&FileEvent> for FileEventPayload { - fn from(e: &FileEvent) -> Self { ... } -} -``` - -This keeps `perima-core` framework-free while letting Tauri emit a -specta-typed payload. - ---- - -## Frontend - -### `apps/desktop/src/types.ts` - -Add `FileEvent`: - -```typescript -export type FileEvent = - | { type: "Created"; path: string; volume: string } - | { type: "Modified"; path: string; volume: string } - | { type: "Deleted"; path: string; volume: string } - | { type: "Renamed"; from: string; to: string; volume: string }; -``` - -### `apps/desktop/src/api.ts` - -```typescript -import { listen } from "@tauri-apps/api/event"; - -export function startWatch(path: string): ResultAsync { - return fromInvoke("start_watch", { path }); -} -export function stopWatch(): ResultAsync { - return fromInvoke("stop_watch", {}); -} -export function isWatching(): ResultAsync { - return fromInvoke("is_watching", {}); -} - -export type UnsubscribeFn = () => void; -export async function subscribeToFileEvents( - callback: (event: FileEvent) => void, -): Promise { - const unlisten = await listen("file-event", (tauriEvent) => { - callback(tauriEvent.payload); - }); - return unlisten; -} -``` - -### `App.tsx` debounced refresh - -```typescript -useEffect(() => { - let timer: ReturnType | null = null; - let unsubscribe: UnsubscribeFn | null = null; - - subscribeToFileEvents(() => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - // re-fetch via api.listFiles - refreshFileList(); - }, 300); - }).then((fn) => { unsubscribe = fn; }); - - return () => { - if (timer) clearTimeout(timer); - if (unsubscribe) unsubscribe(); - }; -}, []); -``` - ---- - -## Testing - -### Backend (Rust) - -- Unit test in `crates/desktop/src/events.rs`: construct a - `FileEventPayload` from each `FileEvent` variant, serialize to - JSON, assert the expected shape. -- Integration test: `start_watch_inner` + `stop_watch_inner` helper - functions (same pattern as scan/list_files). Mock `AppHandle` - with `tauri::test::MockRuntime`. Skip if Tauri test utilities - are too painful — accept unit-only coverage. - -### Frontend (TypeScript) - -- `App.test.tsx`: new test verifying that multiple `file-event`s - within 300ms trigger only ONE `listFiles` call (debounce - behavior). Pattern: - ```typescript - vi.useFakeTimers(); - vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); - // capture the handler passed to listen - const handler = (listen as Mock).mock.calls[0][1]; - // fire 5 events synchronously - for (let i = 0; i < 5; i++) handler({ payload: {...} }); - vi.advanceTimersByTime(300); - expect(invoke).toHaveBeenCalledTimes(1); - expect(invoke).toHaveBeenCalledWith("list_files", ...); - ``` - ---- - -## Exit criteria (autonomously verifiable) - -P3b-1. `start_watch` command creates a watcher that emits Tauri - events. -P3b-2. Frontend subscribes via `subscribeToFileEvents` and the - callback fires when the backend emits. -P3b-3. 300ms debounce: 5 rapid events within 300ms trigger 1 - `listFiles` call. -P3b-4. `cargo build -p perima-desktop` exits 0. -P3b-5. `bun run test` in `apps/desktop` exits 0 (existing 9 + new - debounce test). -P3b-6. `just ci` green. -P3b-7. `phase-3-complete` tag pushed after CI green (this - completes all of phase 3). - ---- - -## Risks - -- **`AppHandle` thread-safety.** Tauri's `AppHandle` is `Clone + - Send + Sync`, so holding it in `TauriEventEmitter: Send + Sync` - is fine. -- **Watcher state across command calls.** The `WatcherState`'s - `Mutex>` can deadlock if `emit` holds a - lock that `stop_watch` also wants. Mitigation: `TauriEventEmitter` - doesn't touch `WatcherState` — it only holds `AppHandle`. -- **Frontend debounce timing.** 300ms is a trade-off. Too short: - wastes DB queries. Too long: UI feels laggy. Acceptable for v1. -- **macOS watcher quirks** (issue #5) still apply — live refresh - works on Linux, partially on macOS (creates land, deletes/renames - depend on notify coalescing). Frontend doesn't care about the - semantic mismatch — any event triggers a re-fetch. diff --git a/docs/superpowers/specs/2026-04-16-v0.3.x-hardening-design.md b/docs/superpowers/specs/2026-04-16-v0.3.x-hardening-design.md deleted file mode 100644 index 9049779..0000000 --- a/docs/superpowers/specs/2026-04-16-v0.3.x-hardening-design.md +++ /dev/null @@ -1,301 +0,0 @@ -# v0.3.x hardening — identity invariants + error surfacing - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** post-v0.3.0 bug-fix sprint. Triggered by codex whole-codebase -review (2026-04-16) which flagged location/mount identity bugs as a -blocker for phase 4. -**GH issues:** utof/perima#7, #8, #9 - ---- - -## Goal - -Close the identity-invariant gaps in `crates/db` and the silent-failure -paths in `crates/desktop` before we start layering secondary state on -top (phase 4: `file_metadata`, thumbnails, background queue). Ship as -patch releases (`v0.3.1`, `v0.3.2`, `v0.3.3`) under the new -conventional-commits convention. - -## Non-goals - -- Phase 4 features (thumbnails, metadata, grid view). -- release-plz wiring (#10) — separate workstream. -- Unused-deps audit (#12) — handled opportunistically alongside other - crate edits. -- Binary distribution CI (#11) — phase 9 concern. - ---- - -## Scope — 3 fixes, 3 commits, 3 patch releases - -### Fix 1 — `fix(db): harden location + mount identity invariants` → v0.3.1 - -Closes utof/perima#7, utof/perima#9 (tests). - -Four sub-problems, fixed together because they all touch the same -read-modify-write pattern: - -#### 1a. `record_mount` retires superseded rows - -`crates/db/src/volume_repo.rs:162-205` currently inserts on new -`(volume_id, machine_id, mount_path)` but never soft-deletes prior -mount rows for the same `(volume_id, machine_id)` with a different -`mount_path`. - -**Fix:** -```rust -// Inside record_mount, within a BEGIN IMMEDIATE transaction: -// 1. Soft-delete any active row for (volume_id, machine_id) whose -// mount_path differs from the new one (set deleted_at = now). -// 2. Upsert the current row: INSERT OR UPDATE on -// (volume_id, machine_id, mount_path). -``` - -**WHY soft-delete rather than hard delete:** CRDT rules require soft -delete on every mutable row. - -#### 1b. `update_location_path` guards rename-to-existing - -`crates/db/src/file_repo.rs:181-220,298-338` rewrites `old → new` -without checking for an existing active row at `new`. - -**Fix:** -```rust -// Inside update_location_path, within a BEGIN IMMEDIATE transaction: -// 1. Check for an existing active row at (volume_id, new_path). -// 2. If present: soft-delete the OLD row (rename collides, the -// existing destination is authoritative — whoever wrote it last -// wins for now; CRDT resolution is phase 8+). -// 3. If absent: UPDATE relative_path as before, status = 'active'. -``` - -**Alternative considered:** mark the existing destination as -`missing` and move the rename source in. Rejected because it -inverts the wins-ties semantics arbitrarily. Soft-deleting the -source row leaves the DB in a defensible state: the file-system -says "file at new_path exists", the DB agrees. - -#### 1c. Race-safe uniqueness for app-level constraints - -SQLite serializes statements, not read-modify-write sequences. -Concurrent CLI + desktop processes can hit `upsert_location`, -`find_or_create_volume`, and `record_mount` simultaneously and -produce duplicate active rows. - -**Fix:** wrap each read-modify-write sequence in a SQLite -`BEGIN IMMEDIATE` + `COMMIT`. `BEGIN IMMEDIATE` acquires the -writer lock atomically, serializing all three operations against -each other across connections. - -Affected sites: -- `SqliteFileRepository::upsert_location` -- `SqliteVolumeRepository::find_or_create` -- `SqliteVolumeRepository::record_mount` - -The existing `Mutex` wrapper doesn't help here — each -crate-spawned connection has its own mutex, and we open multiple -connections (scan, watch, desktop). The transaction is the -serialization point. - -**WHY not `BEGIN DEFERRED`:** deferred acquires the writer lock -on the first write statement, which opens a window for two readers -to both SELECT "not found" and both INSERT. `IMMEDIATE` takes the -lock at BEGIN time. - -#### 1d. Non-UTF8 mount paths — validate at the boundary - -`crates/db/src/volume_repo.rs:172-205,258-266` uses -`to_string_lossy()` which silently corrupts non-UTF8 paths (rare -but possible on Linux). - -**Fix:** replace `to_string_lossy()` with `path.to_str().ok_or(...)` -returning `CoreError::InvalidPath(...)`. `InvalidPath` already -exists in `crates/core/src/errors.rs` and matches the existing -taxonomy — no new variant needed. Storing as `BLOB` was -considered and rejected as overkill. - -#### 1e. Wire `busy_timeout` at connection open (PRECONDITION) - -`crates/db/src/connection.rs:31-36` currently sets `journal_mode`, -`synchronous`, and `foreign_keys` but does NOT call -`conn.busy_timeout(...)`. Without it, concurrent `BEGIN IMMEDIATE` -under contention returns `SQLITE_BUSY` **immediately**, which turns -the uniqueness fix into a correctness regression — callers error -instead of serializing. - -**Fix:** add `conn.busy_timeout(Duration::from_secs(5))?;` to -`open_and_migrate` after the pragma batch. 5s is generous for local -SQLite + small transactions; too-long waits would only bite if a -single transaction ran for seconds, which our per-call batches -never do. - -**WHY `conn.busy_timeout` not `PRAGMA busy_timeout`:** the rusqlite -helper registers a retry callback with exponential backoff that -respects the duration; the raw PRAGMA just sets a magic number the -driver polls. Same outcome in practice, helper is idiomatic. - -### Fix 2 — `fix(desktop): surface watcher errors in app state` → v0.3.2 - -Closes utof/perima#8. - -Three call sites in `apps/desktop/src/App.tsx`: - -1. `subscribeToFileEvents().catch(err => console.warn(...))` — silent. -2. `startWatch(path).match(() => {}, err => console.warn(...))` — silent. -3. `.then(fn => { unsubscribe = fn })` — no error branch; a - rejected promise would fall through to the outer catch. - -**Fix:** introduce a `watcherError` state (or reuse the existing -`error` state with a distinct prefix like `"Watcher error: ..."`). -When either subscribe or startWatch fails, set it. Render a dismissible -banner in `StatusBar` or a new `WatcherBanner` component — non-blocking, -preserves the file table visibility. - -**Also:** `crates/desktop/src/lib.rs:31-59` uses `expect()` for -config resolution + debug binding export. Convert to `Result` + `?`, -let Tauri's init error path handle it. These are the only hard -panics in app code. - -### Release plan — two patch releases, not three - -Tests ship bundled with their corresponding fixes, TDD-style: write the -test → see it fail on current main → apply the fix → see it pass → single -commit. This matches what `release-plz` (#10) will do naturally from -conventional `fix(...):` commits once wired. - -- **v0.3.1** — `fix(db): harden identity invariants` (1a-1e + tests) -- **v0.3.2** — `fix(desktop): surface watcher errors + remove expect()` - -**Note on `upsert_location` LWW:** the existing "update hash in place -on (volume, path) conflict" behavior at -`crates/db/src/file_repo.rs:281-343` is intentional Last-Writer-Wins -semantics — two scanners writing the same path with different hashes -means the more recent scan is authoritative. No behavior change here -beyond wrapping the read-modify-write in `BEGIN IMMEDIATE` (1c). - ---- - -## Architecture impact - -Minimal. No schema changes. No new crates, no new deps, no new error -variants (`InvalidPath` already covers the non-UTF8 case per 1d). One -new pragma-equivalent setting on connection open (`busy_timeout` -per 1e). - -The `BEGIN IMMEDIATE` wrapping changes `SqliteFileRepository` + -`SqliteVolumeRepository` method bodies but not their public API. -Calls remain `fn upsert_location(&self, ...) -> Result<..., CoreError>`; -the transaction is internal. - -**CRDT compliance:** both the soft-delete-on-remount (1a) and the -soft-delete-source-on-rename-collision (1b) preserve the -`deleted_at` / `updated_at` / `device_id` rules. No row is hard- -deleted; retired rows remain observable to future sync. - ---- - -## Testing strategy - -### New unit tests in `crates/db/src/volume_repo.rs` tests module - -- `record_mount_retires_superseded_path` — insert mount A, insert - mount B for same volume+machine, assert A is soft-deleted and only - B is active. -- `record_mount_idempotent_on_same_path` — inserting the same mount - twice should not create duplicate rows. -- `record_mount_rejects_non_utf8_path` — use `OsString::from_vec(&[0xFF])` - on Linux, expect `CoreError::Validation`. Gate `#[cfg(target_os = "linux")]` - (Windows/macOS enforce UTF-8 at the path level). -- `find_or_create_concurrent_unique` — **deterministic interleaving via - `std::sync::Barrier`**: 2 threads, each opens its own `Connection`, - both call `find_or_create` with identical identifiers. A 2-party - Barrier forces both past the initial SELECT before either INSERT. - Assert exactly ONE active row after both join. Post-`BEGIN IMMEDIATE`, - the invariant holds on every run — no loop, no flake. - - **WHY Barrier over loop:** a timing-loop is flaky by construction. - Forcing the interleaving deterministically means the test proves the - invariant in one shot. If we can't force the collision window (some - platforms may schedule past the Barrier too fast), gate behind - `#[ignore]` + a `just stress` recipe rather than flake in default CI. - -### New unit tests in `crates/db/src/file_repo.rs` tests module - -- `update_location_path_collision_softdeletes_source` — insert active - row at `new_path`, call `update_location_path(old_path → new_path)`, - assert `old_path` is soft-deleted and `new_path` is unchanged. -- `update_location_path_normal_case` — regression test: no collision - means relative_path is updated in place. Should already pass; add - anyway to pin the happy path. -- `upsert_location_concurrent_unique` — same pattern as - volume_repo's concurrent test. - -### New unit test in `crates/desktop/src/state.rs` tests module - -- `watcher_state_start_stop_cycle` — construct `WatcherState::new()`, - manually transition `inner` + `cancel` to simulate a start, call a - stop helper that drops `inner` + cancels, assert both fields are - `None` afterward. **Pure state machine test — no filesystem, no - Tauri runtime.** - -**End-to-end watcher coverage is deferred.** `tauri::test::MockRuntime` -mocks IPC but not filesystem — combining it with a real `notify` -watcher in a test is genuinely painful, and the payoff is marginal -since `crates/fs/src/watcher.rs` already has real-filesystem tests. -File an issue for a Playwright end-to-end smoke when a UI test phase -lands (post-v1). See utof/perima#11 candidate. - -### New vitest in `apps/desktop/src/__tests__/App.test.tsx` - -- `shows watcher error when subscribeToFileEvents fails` — mock - `listen` to reject, assert error banner renders. -- `shows watcher error when startWatch fails` — mock invoke to reject - for `start_watch`, trigger a scan-complete, assert banner. - ---- - -## Exit criteria (autonomously verifiable) - -- H-1. `cargo test -p perima-db` — all new tests pass (+ existing). -- H-2. `cargo test --workspace` — no regressions. -- H-3. `bun run test` in `apps/desktop` — existing 10 + 2 new = 12 pass. -- H-4. `just ci` green. -- H-5. `v0.3.1` tagged, CI green on all 3 OS, GH Release created. -- H-6. `v0.3.2` tagged, CI green on all 3 OS, GH Release created. -- H-7. Issues #7, #8, #9 closed with a link to the commit. -- H-8. Codex rescue pass on v0.3.2: confirms the 4 [IMPORTANT] - findings from the 2026-04-16 review are resolved. - ---- - -## Risks - -- **Concurrent-race test determinism.** Timing-loops are flaky by - construction; we use `std::sync::Barrier` to force the interleaving. - If some platform schedules past the Barrier too fast to collide, - gate the test behind `#[ignore]` + a `just stress` recipe rather - than let it flake in default CI. -- **WAL checkpointing under long `BEGIN IMMEDIATE` holds.** Immediate - locks can delay WAL checkpoints. Not a v0.3.x concern at current - scale (each tx is a handful of statements), but worth a `// WHY:` - breadcrumb near the transaction boundary pointing at a future - "consider per-operation savepoints" issue if we ever see WAL growth - under watcher pressure. - ---- - -## Commit plan (summary) - -- **v0.3.1:** `fix(db): harden identity invariants + add rename/mount/race tests` - - Touches: `crates/db/src/file_repo.rs`, `crates/db/src/volume_repo.rs`, - `crates/core/src/error.rs` (maybe), tests in both. -- **v0.3.2:** `fix(desktop): surface watcher errors + remove expect() from run` - - Touches: `apps/desktop/src/App.tsx`, `apps/desktop/src/components/*` (new banner), - `crates/desktop/src/lib.rs`, `apps/desktop/src/__tests__/App.test.tsx`. - -Each release: commit → push → CI green on 3 OS → tag `vX.Y.Z` → push tag → -GH Release with `--generate-notes`. - -Reviewer gate before every commit (spec-compliance + code-quality, same -pattern as phase 3b). diff --git a/docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md b/docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md deleted file mode 100644 index 015d984..0000000 --- a/docs/superpowers/specs/2026-04-16-v0.4.0-thumbnails-metadata-design.md +++ /dev/null @@ -1,538 +0,0 @@ -# v0.4.0 / v0.4.1 — Thumbnails + media metadata - -**Status:** draft v2 (post-reviewer revisions) -**Date:** 2026-04-16 -**Parent:** meta-plan phase 4 -**Prior:** v0.3.2 tag. DB identity invariants hardened; phase 4 unblocked. -**GH issue:** #10 (release-plz — rolled into v0.3.3 shakedown) - ---- - -## Goal - -Extract structured media metadata + generate thumbnails for indexed -media files; surface them in the desktop grid view. Ships across three -tags: - -- **v0.3.3** — `chore(ci): wire release-plz` shakedown (no feature work; - validates the automation before v0.4.0 feature commits land). -- **v0.4.0** — backend: metadata extraction + `file_metadata` table + - `MetadataQueue` + CLI (`perima metadata`, `perima ls --with-metadata`, - watcher integration). **User-visible via CLI only; no UI change.** -- **v0.4.1** — thumbnails + desktop grid view + view-mode toggle. First - user-visible media display. - -**Why three tags not one:** reviewer flagged original v0.4.0 scope as -phase-1-and-2-combined. Splitting isolates release-plz risk from feature -risk, and UI work from backend schema changes. - -## Non-goals - -- Audio / PDF / RAW extractors (v0.5+). -- Persistent queue across process restarts (in-memory for v0.4.x; v0.5+). -- Virtualized grid library (`react-window` etc.) — naive render for v0.4.1. -- Thumbnail GC tied to soft-deleted files (filed as issue post-v0.4.1). -- Multi-worker pool for the queue (single worker; measure before parallel). -- CRDT-conflict resolution for concurrent metadata writes (phase 7+). - ---- - -## v0.3.3 — release-plz shakedown - -### Goal - -Validate `release-plz` on an empty `chore`-only change before any -`feat(media):` commits accumulate. If the PAT / workflow config is wrong, -fail loudly now, not while mid-feature. - -### Deliverables - -- `release-plz.toml` at repo root: - ```toml - [workspace] - publish = false # no crates.io yet - git_tag_enable = true - git_release_enable = true - release_commits = "^(feat|fix|feat!|perf)" - # WHY: chore/docs/ci alone must not cut a release, otherwise every - # workflow bump triggers a version bump. - - [[package]] - name = "perima-desktop" - publish = false # never publishable (Tauri shell) - ``` - -- `.github/workflows/release-plz.yml` — runs on push to `main`, opens - release PRs. Requires `RELEASE_PLZ_TOKEN` secret (fine-grained PAT with - `contents:write` + `pull-requests:write`); documented in the PR body. - -- **Release-PR mode** (not direct-commit): release PR goes through CI - gate before tag lands. Aligns with "never commit unreviewed work" rule - in CLAUDE.md. PR auto-merges on CI green (required-status-checks). - -### How v0.3.3 actually gets tagged - -Two options. Pick (B). - -- (A) Manual tag (`git tag -a v0.3.3 ...; git push`). Simple but doesn't - exercise release-plz. -- (B) Merge a `chore(ci): wire release-plz` commit. Release-plz sees - `chore` → no auto-release. Then manually push a trivial `fix(ci): - release-plz shakedown` commit that triggers release-plz's first live - run. First run cuts **v0.3.3**. **This is the shakedown.** - -If (B) fails: rollback release-plz workflow, file detailed issue, -manually tag v0.3.3, resume v0.4.0 planning. - ---- - -## v0.4.0 — backend media metadata - -### Architecture - -#### Core types — `crates/core/src/metadata.rs` - -```rust -/// Structured metadata extracted from a media file. -/// -/// WHY optional everywhere: not every file has every field (PNG has no -/// `captured_at`; MP4 may have no camera). Partial-information is the -/// norm with EXIF / container tags. -/// -/// WHY `captured_at: Option` (ISO 8601) not `DateTime`: -/// consistency with existing `first_seen`/`last_seen` String columns; -/// keeps `chrono` out of `perima-core`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct MediaMetadata { - pub hash: BlakeHash, - pub width: Option, - pub height: Option, - pub duration_ms: Option, - pub captured_at: Option, - pub camera_make: Option, - pub camera_model: Option, - pub codec: Option, - pub bitrate_bps: Option, - pub mime_type: Option, -} - -/// MIME-dispatched extractor. -/// -/// WHY MIME dispatch not "first-non-empty": a JPEG EXIF extractor -/// returning `{width: None, mime_type: Some}` would falsely "win" -/// against a video extractor that could actually extract duration. -/// Dispatch by `accepts(mime)` avoids this ambiguity. -pub trait MetadataExtractor: Send + Sync { - /// Whether this extractor handles the given MIME type. - fn accepts(&self, mime: &str) -> bool; - - /// Extract metadata from the file at `absolute_path`. - fn extract(&self, hash: BlakeHash, path: &Path, mime: &str) - -> Result; -} -``` - -#### Repo port — `crates/core/src/ports/metadata_repo.rs` - -```rust -/// Persistence boundary for `file_metadata`. -/// -/// WHY `&self` everywhere (not `&mut self` like FileRepository): the -/// existing FileRepository trait's `&mut self` collides with the -/// `Arc` pattern used in desktop state. New -/// traits align with the actual usage — interior mutability via -/// `Mutex`. FileRepository will migrate in v0.5.x (filed). -pub trait MetadataRepository: Send + Sync { - fn upsert_metadata( - &self, - meta: &MediaMetadata, - device: DeviceId, - ) -> Result; - - fn find_by_hash( - &self, - hash: &BlakeHash, - ) -> Result, CoreError>; - - /// List `(file_location, metadata)` pairs. `None` metadata means - /// the extractor hasn't run yet (scan enqueued but worker behind) - /// or extraction failed. - fn list_with_metadata( - &self, - limit: usize, - volume: Option, - ) -> Result)>, CoreError>; -} -``` - -File an issue to align `FileRepository` with `&self` in v0.5.x — -out-of-scope for v0.4.0. - -#### Schema: V002 migration - -```sql -CREATE TABLE file_metadata ( - blake3_hash TEXT PRIMARY KEY, -- content-addressed, matches files.blake3_hash - width INTEGER, - height INTEGER, - duration_ms INTEGER, - captured_at TEXT, - camera_make TEXT, - camera_model TEXT, - codec TEXT, - bitrate_bps INTEGER, - mime_type TEXT, - -- CRDT columns (phase 1 rule: every mutable row): - extracted_at TEXT NOT NULL, -- first extraction time - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - --- WHY partial index: most metadata rows have NULL captured_at --- (only images+video have it); partial index saves ~80% of space --- on a photo library. -CREATE INDEX idx_file_metadata_captured - ON file_metadata(captured_at) - WHERE captured_at IS NOT NULL AND deleted_at IS NULL; -``` - -Additive, schema-freeze-compliant. No FK cascades (CRDT rule); app-level -join on `files.blake3_hash = file_metadata.blake3_hash`. - -#### New crate: `crates/media` - -``` -crates/media/ -├── Cargo.toml # image = "0.25", kamadak-exif = "0.5", -│ # nom-exif or mp4parse = "0.12" (TBD by impl), -│ # mime_guess = "2" -├── src/ -│ ├── lib.rs # pub mod extractor; -│ ├── extractor.rs # ImageExtractor, VideoExtractor, CompositeExtractor -│ └── tests/ -│ └── fixtures.rs # runtime-generated test assets -``` - -No thumbnails in v0.4.0 — that's v0.4.1. Keeping the crate focused on -metadata first. - -**Dep choice for video:** `mp4parse` (Mozilla's, stable, narrow scope) -over `nom-exif` (broader but RC-ish). Confirm during implementation; -implementer may flip if mp4parse doesn't expose framerate. - -#### Queue — `crates/media/src/queue.rs` - -```rust -use perima_core::{BlakeHash, CoreError}; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; - -/// Bounded channel capacity. WHY 16384: enough to absorb a 100k-file -/// scan's initial burst without blocking the scanner, while bounding -/// worst-case memory (16384 * sizeof(Work) ~= ~1 MB). -const QUEUE_CAPACITY: usize = 16384; - -pub struct MetadataQueue { - tx: mpsc::Sender, -} - -struct Work { - hash: BlakeHash, - absolute_path: PathBuf, - mime: String, -} - -impl MetadataQueue { - /// Spawn the background worker and return a queue handle for - /// producers. - pub fn spawn( - extractor: Arc, - repo: Arc, - device: DeviceId, - cancel: CancellationToken, - ) -> Self { - let (tx, mut rx) = mpsc::channel::(QUEUE_CAPACITY); - tokio::spawn(async move { - while let Some(work) = rx.recv().await { - if cancel.is_cancelled() { break; } - if let Err(e) = process(&extractor, &repo, device, &work) { - tracing::warn!( - error = %e, - hash = %work.hash, - "metadata extraction failed; will retry on next scan" - ); - } - } - }); - Self { tx } - } - - /// Sync enqueue callable from the scanner (which runs on the main - /// tokio task but is not `async`). - /// - /// WHY `try_send` + cancellation-polling retry: `blocking_send` - /// would block a runtime thread unresponsively to Ctrl-C. - /// `try_send` with bounded polling keeps cancellation responsive - /// even if the worker is saturated. - pub fn enqueue( - &self, - hash: BlakeHash, - absolute_path: PathBuf, - mime: String, - cancel: &CancellationToken, - ) -> Result<(), CoreError> { - let mut work = Work { hash, absolute_path, mime }; - loop { - if cancel.is_cancelled() { - return Err(CoreError::Internal("cancelled during enqueue".into())); - } - match self.tx.try_send(work) { - Ok(()) => return Ok(()), - Err(mpsc::error::TrySendError::Full(w)) => { - work = w; - std::thread::sleep(std::time::Duration::from_millis(50)); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return Err(CoreError::Internal( - "metadata queue worker exited".into(), - )); - } - } - } - } -} -``` - -#### Scanner integration — `crates/cli/src/cmd/scan.rs` - -After each successful `upsert_file` (status = Inserted OR Updated — -content changed), enqueue into the metadata queue. The queue is created -in `scan::run` (owner of the cancel token already). Scan returns -immediately after walking; queue drains in the background. - -Watcher integration — `crates/cli/src/cmd/watch.rs` and -`crates/desktop/src/commands.rs`: -- `FileEvent::Modified` → the watcher's existing `DbEventHandler` marks - status = Stale. On the NEXT scan that re-hashes and upserts, metadata - re-extracts naturally. **We do NOT enqueue from the watcher itself in - v0.4.0.** This keeps the watcher simple and avoids Modified storms - (a download in progress fires dozens of Modified events before - settling). Tracked for v0.5 as "watcher-driven metadata" if users - complain. - -Decision rationale also in CHANGELOG. - -#### CLI additions - -- `perima metadata ` — force re-extract for a single file. Reads - the DB for its hash, enqueues, waits for the row to appear (bounded - by timeout). Useful for debugging. -- `perima ls --with-metadata` — extends the ls output with `captured_at`, - `dimensions`, `camera_model` columns. Default without the flag keeps - the v0.3.x table unchanged (non-breaking for scripts). - -#### Tauri commands - -- `list_files_with_metadata(limit, volume) -> Vec` — - calls `MetadataRepository::list_with_metadata`, wraps into specta- - friendly payload. Not visible yet in v0.4.0 UI (command exists; UI - consumes in v0.4.1). - -No thumbnail-serving command in v0.4.0 (no thumbnails yet). - -### Tests (v0.4.0) - -- `crates/media/src/extractor.rs`: - - `image_extractor_png_dimensions` — 100×50 PNG at runtime, assert - width=100 height=50. - - `image_extractor_jpeg_exif_datetime` — JPEG with EXIF DateTimeOriginal - assembled at test runtime via `image` + `kamadak-exif`, assert - `captured_at` parses to the known timestamp. - - `video_extractor_mp4_duration` — **generate a trivial MP4 at test - runtime** via `mp4` crate (or similar; see impl note). Do NOT commit - binary fixtures to git (reviewer flagged; CLAUDE.md already gitignores - `.md`, implicit policy against binaries). - - `composite_extractor_dispatches_by_mime` — MIME-driven dispatch, not - first-non-empty. - - `extractor_unsupported_mime_returns_empty` — MP3 goes through, returns - empty MediaMetadata, no error. -- `crates/media/src/queue.rs`: - - `queue_processes_work` — 5 items → mock repo has 5 rows. - - `queue_exits_on_cancel` — cancel after 10 of 100 items, fewer - than 100 processed. - - `queue_enqueue_returns_on_cancel` — cancel while queue full, enqueue - returns error promptly (< 500ms). -- `crates/db/src/metadata_repo.rs`: - - `upsert_metadata_inserts_new` / `unchanged_on_repeat` / `updated_on_change`. - - `list_with_metadata_joins_null` — file without metadata row yields - `(FileLocation, None)`. -- Integration test `crates/cli/tests/scan_with_metadata_test.rs` — - scan tempdir with 1 PNG + 1 JPEG + 1 MP4, wait up to 3s, assert 3 - metadata rows in DB. - -### Exit criteria (v0.4.0) - -- P4-0a. release-plz workflow cuts **v0.3.3** from a chore commit. -- P4-0b. release-plz then cuts **v0.4.0** from accumulated - `feat(media):` + `feat(db):` + `feat(cli):` commits. -- P4-0c. `just ci` green; all new tests pass. -- P4-0d. `perima scan` over a 50-file test corpus completes + produces - 50 `file_metadata` rows within 5 seconds post-scan-exit. -- P4-0e. `perima ls --with-metadata` shows the new columns. -- P4-0f. Watcher's `Modified` → `Stale` still works (no regression). -- P4-0g. No regressions in v0.3.x tests (34 DB + 11 TS). - ---- - -## v0.4.1 — thumbnails + grid view - -### Thumbnail generator — `crates/media/src/thumbnail.rs` - -```rust -/// Generates content-addressed WebP thumbnails. -/// -/// Path: `/thumbnails//.webp` -/// Writes atomically: `.tmp` + `fs::rename` to avoid -/// half-written thumbnails from crashes mid-encode (TOCTOU-safe). -pub struct ThumbnailGenerator { - data_dir: PathBuf, - max_size: u32, // default 256 -} - -impl ThumbnailGenerator { - pub fn path_for(&self, hash: BlakeHash) -> PathBuf; - - /// Generate + atomically install. No-op if target exists and is - /// non-zero size (the rename step means existing == fully written). - pub fn generate(&self, hash: BlakeHash, source: &Path) -> Result; -} -``` - -Encode path: decode via `image::open`, resize `Lanczos3` to `max_size` -max-dim preserving aspect, encode via `image::codecs::webp::WebPEncoder` -q=85. Write to `.tmp` → `fs::rename` → exists-check is sound. - -### Schema updates (v0.4.1) - -Add to `file_metadata`: -- `thumbnail_path TEXT` — relative to data_dir. -- `thumbnail_status TEXT NOT NULL DEFAULT 'pending'` — `pending`, `ready`, `failed`. - -V003 migration, additive. No UNIQUE on new cols. - -### Queue extension - -`MetadataQueue::spawn` now takes a `ThumbnailGenerator` too. Worker: -1. Extract metadata (existing). -2. If `kind in (image, video)`, generate thumbnail; set `thumbnail_status` - + `thumbnail_path`. On extractor/encoder failure, set `failed`. - -### Grid view (frontend) - -- `apps/desktop/src/components/FileGrid.tsx` — 200px tiles, CSS grid - `auto-fill minmax(200px, 1fr)`. -- `FileGridTile` — if `thumbnail_status == 'ready'`, ``; - else placeholder SVG (file-type icon). -- `App.tsx` — view-mode toggle button in header: Table | Grid. Default - Table (v0.3.x UX continuity). State survives component lifecycle. -- New vitest: `FileGrid.test.tsx` — 3 tiles (ready / pending / failed), - assert correct render per state. - -### Tests (v0.4.1) - -- `thumbnail_generate_256x256_preserves_aspect` — 1000×500 → 256×128. -- `thumbnail_atomic_write` — simulate mid-write panic; assert no - partial `.webp` file remains (the `.tmp` guarantees this). -- `thumbnail_idempotent` — calling generate twice is a no-op (exists - check passes). -- Frontend `FileGrid.test.tsx` per above. - -### Exit criteria (v0.4.1) - -- P4-1a. release-plz cuts **v0.4.1** from accumulated commits. -- P4-1b. Thumbnails generated on-disk at `/thumbnails//...`. -- P4-1c. Grid view renders thumbnails; pending tiles show placeholder. -- P4-1d. View-mode toggle works; default is Table. -- P4-1e. Vitest `FileGrid.test.tsx` + existing 11 TS tests pass. -- P4-1f. `just ci` green. - ---- - -## Dependencies added (workspace) - -```toml -image = "0.25" -kamadak-exif = "0.5" -mp4parse = "0.17" # confirm latest in impl -mime_guess = "2" -``` - -None added to `perima-core`. All confined to `crates/media`. - ---- - -## Risks - -- **`image-webp` (pure Rust) quality.** `image` v0.25 uses its own - pure-Rust WebP encoder, noticeably inferior to libwebp at the same - bitrate. For 256×256 thumbnails it's fine. Swap to the `webp` crate - (libwebp-sys) if user complaints arrive. -- **`mp4parse` limitations.** Mozilla's demuxer; covers mp4/mov but - not all container quirks. If extraction fails on real-world files, - add a fallback extractor or narrow scope further. -- **Queue back-pressure under huge scans.** 16384 capacity handles 100k - scans; beyond that, `enqueue` polls with 50ms sleeps while cancel is - watched. Measured throughput should be documented after first real - scan. -- **`FileRepository &mut self` legacy.** Kept as-is for v0.4.0; new - repo uses `&self`. Migrating FileRepository to `&self` is a v0.5.x - fast-follow. File an issue. -- **release-plz first run.** If the PAT is misconfigured or - release-PR mode misfires, v0.3.3 won't tag. Plan (B) in v0.3.3 - acknowledges this; rollback path is clear. - ---- - -## Deferrals (all filed as issues on approval) - -1. Migrate `FileRepository` trait from `&mut self` to `&self` (matches - actual usage). -2. Watcher-driven metadata extraction (currently defers to next scan). -3. Thumbnail GC tied to soft-deleted files. -4. Multi-worker queue parallelism. -5. Persistent queue across process restarts. -6. Audio / PDF / RAW extractors. -7. Virtualized grid rendering for large file libraries. - ---- - -## Self-review - -**Reviewer blockers addressed:** -- BLOCKER #1 (runtime mixing): `enqueue` is sync with `try_send` + - cancellation-polling retry. Documented WHY. -- BLOCKER #2 (MetadataRepository `&self`): trait uses `&self`; - FileRepository legacy acknowledged + deferred. - -**Reviewer importants addressed:** -- Scope split into v0.3.3 + v0.4.0 + v0.4.1 ✓ -- release-plz as v0.3.3 shakedown (option B) ✓ -- `perima ls --with-metadata` + default unchanged ✓ -- Watcher wiring explicit: no watcher-enqueue in v0.4.0 (Modified → - Stale → next scan re-extracts) ✓ -- Queue `enqueue` cancellation-responsive ✓ -- Video fixture generated at test runtime, not committed ✓ -- Thumbnail atomic rename (v0.4.1) ✓ -- Partial index includes `deleted_at IS NULL` ✓ - -**Reviewer nits addressed:** -- `captured_at: String` retained with WHY comment ✓ -- CompositeExtractor dispatch-by-MIME not first-non-empty ✓ -- MetadataExtractor sync ✓ -- Single worker ✓ -- Table default for grid ✓ -- `crates/media` separation ✓ -- V002 CRDT-compliant; `extracted_at` behavior documented ✓ diff --git a/docs/superpowers/specs/2026-04-16-v0.5-tags-design.md b/docs/superpowers/specs/2026-04-16-v0.5-tags-design.md deleted file mode 100644 index 621b75e..0000000 --- a/docs/superpowers/specs/2026-04-16-v0.5-tags-design.md +++ /dev/null @@ -1,348 +0,0 @@ -# v0.5.x — Tags - -**Status:** draft awaiting reviewer -**Date:** 2026-04-16 -**Parent:** meta-plan phase 5a -**Prior:** v0.4.1 tag. Backend media + grid UI shipped. -**GH issues:** none yet — filed after spec approval. - ---- - -## Goal - -Let users tag files with arbitrary string labels + filter the file list -(Table and Grid) by tag. First non-metadata user-facing organization -primitive. - -Ships across two tags: - -- **v0.5.0** — backend: `tags` + `file_tags` tables, `TagRepository`, - CLI (`perima tag`, `perima untag`, `perima tags`, `perima ls --tag`). - Desktop-invisible. -- **v0.5.1** — desktop UI: tag chips on FileTable/FileGrid rows; tag - sidebar for filtering; Tauri commands. - -## Non-goals - -- Hierarchy / nested tags (flat labels for v0.5; nested post-v1). -- Auto-tagging by rule / ML (phase 6+ or user-script plugin post-v1). -- Color assignment (v0.5: procedural from tag name hash in frontend; - user-override picker deferred). -- Search beyond tag filtering (phase 5b — FTS5 over filename + - metadata + tags). -- Tag renames with reference-following (treat as "create new + migrate" - in userland; proper rename is post-v1). - ---- - -## Architecture - -### Schema (V004 migration, additive) - -```sql --- crates/db/migrations/V004__tags.sql - -CREATE TABLE tags ( - id TEXT PRIMARY KEY, -- UUIDv7 - name TEXT NOT NULL, -- NFC-normalized lowercase label - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - --- App-level uniqueness on `(name, deleted_at IS NULL)` — NOT a UNIQUE --- constraint (CLAUDE.md rule); SELECT-then-INSERT under BEGIN IMMEDIATE --- in the repo, mirroring v0.3.1 hardening. -CREATE INDEX idx_tags_name_active - ON tags(name) - WHERE deleted_at IS NULL; - -CREATE TABLE file_tags ( - id TEXT PRIMARY KEY, -- UUIDv7 - blake3_hash TEXT NOT NULL, - tag_id TEXT NOT NULL, - first_seen TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - device_id TEXT NOT NULL -); - --- Composite covering index for both lookup directions + --- app-level uniqueness SELECT under BEGIN IMMEDIATE. A single --- (hash, tag) index beats two single-column ones for the common --- "does this file have this tag" + "list tags on file" queries. -CREATE INDEX idx_file_tags_hash_tag_active - ON file_tags(blake3_hash, tag_id) - WHERE deleted_at IS NULL; --- Reverse lookup for "which files have this tag" (sidebar filter). -CREATE INDEX idx_file_tags_tag_active - ON file_tags(tag_id) - WHERE deleted_at IS NULL; -``` - -Both tables are content-adjacent (keyed by blake3_hash) — tagging is a -property of content, not a specific file location. If the same file -content appears at two paths, both inherit the same tags. Consistent -with how `file_metadata` is content-addressed. - -**WHY no FK cascades:** CLAUDE.md rule. App-level join enforces integrity. - -### New module: `crates/core/src/tag.rs` - -Framework-free value type + normalization + port. - -```rust -/// A tag — user-assignable lightweight label on content. -/// -/// WHY only `id`/`name`/`first_seen` exposed: `updated_at`/`device_id`/ -/// `deleted_at` are CRDT bookkeeping the UI never needs. Repo layer -/// owns them. Future ffi/api adapters get the same shape — the storage -/// columns are not part of the domain contract. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Tag { - pub id: Uuid, // UUIDv7 - pub name: String, // NFC-normalized lowercase, trimmed - pub first_seen: String, // ISO 8601 -} - -/// Normalize a user-supplied tag name to its canonical form. -/// -/// NFC-normalize, lowercase, trim. Reject empty + longer than -/// `MAX_TAG_LEN` (64 chars post-normalization). -/// -/// Lives in core (not repo) so the CLI, HTTP API, and FFI adapters -/// all share one validation path — mirrors `MediaPath::new`'s design. -pub fn normalize(name: &str) -> Result; - -pub const MAX_TAG_LEN: usize = 64; - -pub trait TagRepository: Send + Sync { - /// Create or find a tag by name (idempotent). Normalizes `name` - /// via `tag::normalize`. Returns the active tag row. - fn upsert_tag(&self, name: &str, device: DeviceId) -> Result; - - /// Soft-delete a tag. Preserves `file_tags` rows — recreating the - /// same name later produces a NEW tag id with no prior attachments - /// (per open-question #2 resolution). - fn delete_tag(&self, tag_id: Uuid, device: DeviceId) -> Result<(), CoreError>; - - /// Associate a tag with a file. Idempotent (second call returns - /// `Unchanged`). - fn attach(&self, hash: &BlakeHash, tag_id: Uuid, device: DeviceId) - -> Result; - - /// Soft-delete a file_tags row. - fn detach(&self, hash: &BlakeHash, tag_id: Uuid, device: DeviceId) - -> Result<(), CoreError>; - - /// List all active tags, sorted by name. - fn list_tags(&self) -> Result, CoreError>; - - /// Tags attached to a specific file. - fn tags_for_file(&self, hash: &BlakeHash) -> Result, CoreError>; - - /// Batch: tags attached to multiple files. Used by Tauri command - /// that merges `list_files_with_metadata` + tags without a - /// GROUP BY / group_concat JOIN on the existing query. - fn tags_for_hashes(&self, hashes: &[BlakeHash]) - -> Result>, CoreError>; -} -``` - -**No `files_with_tag` method.** Frontend filters client-side from the -merged `list_files_with_metadata` + `tags_for_hashes` response. CLI -`ls --tag` filters the same way. - -All methods `&self` with interior mutability via `Mutex` — -matches `MetadataRepository` pattern from v0.4.0. - -### New error variant - -`CoreError::InvalidTag(String)` — dedicated variant (not reused -`InvalidPath`). Error taxonomy is cheap to extend; stable error codes -matter for the HTTP API + FFI adapters in later phases. - -### Tag name normalization - -Rule: **NFC-normalize + lowercase + trim**. Enforced at the repository -boundary (in `upsert_tag`). - -- NFC normalization — matches `MediaPath` policy (unicode-normalization - crate already a workspace dep). -- Lowercase — case-insensitive tag matching; "Vacation" and "vacation" - are the same tag. -- Trim whitespace — prevents invisible-prefix attacks / accidental space - duplicates. -- Length cap: 64 chars after normalization (see `MAX_TAG_LEN`). - Reject longer with `CoreError::InvalidTag`. -- Empty names (or whitespace-only) rejected. - -Tags store the normalized form; UI displays as-stored. - -### CLI commands (v0.5.0) - -Grouped subcommand under `tag` — symmetric, extensible for future -`tag rename`, matches clap idioms: - -``` -perima tag add [...] - Tag a file with one or more labels. Creates tag rows on-the-fly. - -perima tag rm - Soft-delete the file_tags row. The tag row itself persists. - -perima tag ls [--json] - List all active tags + attached-file count per tag. - -perima ls --tag - Filter `perima ls` by tag. Stacks with --volume / --with-metadata. -``` - -`perima tag add` reuses `perima metadata`'s suffix-match path-to-hash -lookup. - -### Tauri commands (v0.5.1) - -```rust -#[tauri::command] #[specta::specta] -async fn list_tags() -> Result, String>; - -#[tauri::command] #[specta::specta] -async fn attach_tag(hash: String, tag_name: String) -> Result<(), String>; - -#[tauri::command] #[specta::specta] -async fn detach_tag(hash: String, tag_id: String) -> Result<(), String>; - -#[tauri::command] #[specta::specta] -async fn list_files_with_tags(limit: u32, volume: Option) - -> Result, String>; -``` - -**`list_files_with_metadata` is NOT extended with tags.** New wrapper -command `list_files_with_tags` composes: - -1. Call existing `metadata_repo.list_with_metadata(limit, volume)`. -2. Collect the hash set. -3. Call `tag_repo.tags_for_hashes(&hashes)`. -4. Merge in Rust; emit `FileWithTagsPayload` = existing fields + - `tags: Vec`. - -**WHY two queries, not a GROUP BY + group_concat JOIN:** group_concat -loses tag `id` (needs delimiter escaping for names containing commas), -produces unbounded strings, requires post-parsing. Two targeted -queries under WAL add <5 ms and the SQL stays trivially correct. - -Frontend filtering (Table / Grid view by selected sidebar tag) is pure -client-side on the `FileWithTagsPayload[]` — no separate -`files_with_tag` IPC needed. - -### Desktop UI (v0.5.1) - -- **Tag chip row** on each FileTable row and below each FileGrid tile. - Small rounded pill with tag name; color procedurally derived from - `blake3(tag_name).as_bytes()[0] % 12` into a Tailwind color palette. - (blake3 already a workspace dep; no new crate.) - Cap visible chips at 3 per row with "+N" overflow badge. -- **Tag sidebar** — new left column when any tag exists. Click a tag → - file list filters to files with that tag (state in App.tsx modelled - as `Set` even though v0.5.1 only lets the user select ONE - at a time — this keeps multi-select a pure UI change post-v1 without - refactoring). Click "all" → clear filter. -- **Tag-add affordance:** inline text input on each row (focus → type → - comma or Enter to commit). Autocomplete from existing tags via a - simple prefix match on the in-memory `list_tags()` snapshot. -- **Tag-remove:** × button inside each chip. - ---- - -## CRDT compliance - -Both tables carry `updated_at` + `device_id` + `deleted_at`. Tags -soft-delete; a `DELETE FROM tags WHERE deleted_at IS NOT NULL` is never -issued. - -**`attach` is CRDT-merge-friendly** by construction: `(blake3_hash, -tag_id)` is the logical unique pair. Two devices attaching the same -tag to the same file independently produce two rows, which the -eventual sync layer (phase 7+) deduplicates by preferring the -earlier `first_seen`. The `attach` repo method's idempotence check -(SELECT active row) prevents duplicates on a single device. - -## Testing strategy - -### Rust — `crates/core/src/tag.rs` tests (normalize) - -- `normalize_lowercases_and_nfc` -- `normalize_trims` -- `normalize_rejects_empty` -- `normalize_rejects_too_long` (> MAX_TAG_LEN) - -### Rust — `crates/db/src/tag_repo.rs` tests - -- `upsert_tag_inserts_new` -- `upsert_tag_idempotent_on_repeat` — same name twice returns same id -- `upsert_tag_normalizes_case_and_nfc` — "Vacation", "VACATION", - NFC-decomposed composition all collapse to the same row -- `attach_inserts_new`, `attach_idempotent_on_repeat` -- `detach_softdeletes` -- `tags_for_file` + `tags_for_hashes` correctness -- **`delete_preserves_attachments`** (regression for deletion semantics) -- **`recreate_after_delete_yields_new_id`** (pins the "deleted tag + - same-name upsert = new id" contract — deliberately surprising, - needs a test to prevent silent drift) -- Concurrent `upsert_tag` via `std::sync::Barrier` (same pattern as - v0.3.1's `find_or_create_concurrent_unique`) - -### Integration test `crates/cli/tests/tag_cli_test.rs` - -- Scan a tempdir with 2 files, tag one with two labels, untag one label, - assert DB state via `tags_for_file`. -- `perima ls --tag ` returns only tagged files. - -### Frontend — `apps/desktop/src/__tests__/TagSidebar.test.tsx` - -- 3 tags render + clicking one filters files. -- Chip removal fires detach mutation. - ---- - -## Exit criteria - -- **v0.5.0** - - V004 migration clean on existing v0.4.x DBs. - - CLI tag/untag/tags commands work end-to-end. - - `perima ls --tag foo` filters correctly. - - `just ci` green. - - release-plz auto-tags v0.5.0 from chore(release) commit. -- **v0.5.1** - - Tag chips visible on every row (Table + Grid). - - Tag sidebar filters file list. - - Tag add/remove mutations work. - - 2+ new vitest tests (sidebar + chip behavior). - - `just ci` green; release-plz auto-tags v0.5.1. - ---- - -## Resolved open questions (from reviewer pass) - -1. **Add `CoreError::InvalidTag(String)`** — dedicated variant, not reused InvalidPath. -2. **Deletion semantics:** soft-delete preserves `file_tags` rows in the DB for CRDT merge, BUT the UI contract is "deleted tags are gone for good — recreating by same name is a NEW tag with no prior attachments." Trade-off between data preservation and mental model clarity; we pin the new-id behavior with a test. -3. **Colors:** strictly procedural via `blake3(name)[0] % 12` for v0.5.1. Separate `tag_colors` table deferred post-v1 for user-overrides. -4. **Autocomplete:** strict prefix-match for v0.5.1. Fuzzy ranking deferred. -5. **JOIN scope:** do NOT extend `list_files_with_metadata` with tags. Introduce separate Tauri command `list_files_with_tags` that calls both repo methods (metadata + tags_for_hashes) and merges in Rust. Keeps existing SQL clean; adds <5 ms under WAL. - ---- - -## Risks - -- **LEFT JOIN + GROUP BY + group_concat.** SQLite's `group_concat(tag_name)` - has a 1GB string cap; impossibly out of reach for v1 scale but worth - knowing. -- **Tag name case folding.** Full unicode case folding is nontrivial; - for v0.5 we use `str::to_lowercase()` which covers ASCII + most - Latin. Turkish dotless-i and edge cases may collide incorrectly; - acceptable for v1, filed post-v1. -- **UI clutter.** Chip row per file grows vertical space. Cap visible - chips at 3 with "+N more" overflow — pick up from Gmail's label row - design. diff --git a/docs/superpowers/specs/2026-04-16-v0.6-search-design.md b/docs/superpowers/specs/2026-04-16-v0.6-search-design.md deleted file mode 100644 index 40616b3..0000000 --- a/docs/superpowers/specs/2026-04-16-v0.6-search-design.md +++ /dev/null @@ -1,316 +0,0 @@ -# v0.6 — FTS5 Full-Text Search Design - -**Status:** Draft — ready for implementation. - -**Goal:** Fast, offline, full-text search over indexed file metadata. v0.6.0 ships the backend (FTS5 table + CLI). v0.6.1 ships the desktop UI (Tauri command + React SearchBar). - ---- - -## 1. What to Index - -The FTS5 virtual table indexes a composite document per (hash, volume_id, relative_path) row. Each document contains: - -| Field | Source table | Notes | -|-------|-------------|-------| -| `filename` | `files.relative_path` | basename only (last path segment) | -| `relative_path` | `files.relative_path` | full path for path-prefix queries | -| `mime_type` | `file_metadata.mime_type` | `image/jpeg`, `video/mp4`, etc. | -| `camera_model` | `file_metadata.camera_model` | EXIF camera string | -| `captured_at` | `file_metadata.captured_at` | ISO 8601 string; enables `date:2024-*` queries | -| `tags` | aggregate of `tags.name` | space-separated list of tag names for the hash | - -WHY these six fields: they cover the primary search axes users reach for — filename (most common), type filter (mime_type), gear filter (camera_model), date filter (captured_at), and label filter (tags). Path prefix enables folder-scope queries. Adding more fields later is additive (FTS5 table rebuild). - -WHY FTS5 (not FTS4, not full-table LIKE): FTS5 ships with SQLite 3.9+ (Ubuntu 24.04 has 3.45), supports unicode61 tokenizer, trigram tokenizer (for substring match), built-in ranking via `bm25()`, and `highlight()`/`snippet()` for UI hits. No external dep. - ---- - -## 2. Schema - -### V006 Migration - -```sql --- V006__search.sql - --- FTS5 virtual table (content table backed by the view below). --- WHY content='': contentless FTS5 avoids storing a duplicate copy of the --- text. The downside is that UPDATE requires a manual delete+insert cycle --- (handled by triggers below). We use 'content=""' mode to save space; --- for the rebuild path we drop and re-create the table entirely. -CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( - filename, - relative_path, - mime_type, - camera_model, - captured_at, - tags, - -- rowid maps to a synthetic integer key per document; we store the - -- (hash, volume_id, relative_path) triple in a side table for lookup. - content="" -); - --- Side table: maps FTS5 rowid → (hash, volume_id, relative_path). --- WHY separate table: FTS5 contentless mode stores only the inverted index, --- not the original row values. Lookup after a search hit needs a way to --- join back to files + file_metadata. -CREATE TABLE IF NOT EXISTS search_rowid_map ( - rowid INTEGER PRIMARY KEY, - blake3_hash TEXT NOT NULL, - volume_id TEXT NOT NULL, - relative_path TEXT NOT NULL -); - --- Metadata triggers: keep search_index in sync on file_metadata changes. --- WHY triggers (not periodic rebuild): trigger latency is negligible for --- single-row upserts (scan adds one file at a time); periodic rebuild would --- add a background job and stale-window complexity. The rebuild path --- (SqliteSearchRepository::rebuild) is retained for migration / corruption --- recovery and is exposed via CLI as `perima search --rebuild`. - -CREATE TRIGGER IF NOT EXISTS search_after_metadata_insert -AFTER INSERT ON file_metadata BEGIN - -- Resolved via subselect; new doc gets a fresh rowid. - INSERT INTO search_rowid_map (blake3_hash, volume_id, relative_path) - SELECT NEW.blake3_hash, f.volume_id, f.relative_path - FROM files f - WHERE f.blake3_hash = NEW.blake3_hash - LIMIT 1; - - INSERT INTO search_index (rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT last_insert_rowid(), - COALESCE( - CASE WHEN INSTR(f.relative_path, '/') > 0 - THEN SUBSTR(f.relative_path, LENGTH(f.relative_path) - INSTR(REVERSE(f.relative_path), '/') + 2) - ELSE f.relative_path END, ''), - f.relative_path, - COALESCE(NEW.mime_type, ''), - COALESCE(NEW.camera_model, ''), - COALESCE(NEW.captured_at, ''), - COALESCE((SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = NEW.blake3_hash AND ft.deleted_at IS NULL), '') - FROM files f WHERE f.blake3_hash = NEW.blake3_hash LIMIT 1; -END; - -CREATE TRIGGER IF NOT EXISTS search_after_metadata_update -AFTER UPDATE ON file_metadata BEGIN - -- Delete old entry for this hash (all locations share the same doc). - INSERT INTO search_index (search_index, rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT 'delete', srm.rowid, '', '', '', '', '', '' - FROM search_rowid_map srm WHERE srm.blake3_hash = NEW.blake3_hash; - - -- Re-insert with fresh data. - INSERT INTO search_index (rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT srm.rowid, - COALESCE( - CASE WHEN INSTR(f.relative_path, '/') > 0 - THEN SUBSTR(f.relative_path, LENGTH(f.relative_path) - INSTR(REVERSE(f.relative_path), '/') + 2) - ELSE f.relative_path END, ''), - f.relative_path, - COALESCE(NEW.mime_type, ''), - COALESCE(NEW.camera_model, ''), - COALESCE(NEW.captured_at, ''), - COALESCE((SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = NEW.blake3_hash AND ft.deleted_at IS NULL), '') - FROM search_rowid_map srm JOIN files f ON f.blake3_hash = srm.blake3_hash - WHERE srm.blake3_hash = NEW.blake3_hash LIMIT 1; -END; - -CREATE TRIGGER IF NOT EXISTS search_after_file_tags_insert -AFTER INSERT ON file_tags BEGIN - -- Tag attachment: rebuild tags column for affected hash. - INSERT INTO search_index (search_index, rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT 'delete', srm.rowid, '', '', '', '', '', '' - FROM search_rowid_map srm WHERE srm.blake3_hash = NEW.blake3_hash; - - INSERT INTO search_index (rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT srm.rowid, - COALESCE( - CASE WHEN INSTR(f.relative_path, '/') > 0 - THEN SUBSTR(f.relative_path, LENGTH(f.relative_path) - INSTR(REVERSE(f.relative_path), '/') + 2) - ELSE f.relative_path END, ''), - f.relative_path, - COALESCE(m.mime_type, ''), - COALESCE(m.camera_model, ''), - COALESCE(m.captured_at, ''), - COALESCE((SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = NEW.blake3_hash AND ft.deleted_at IS NULL), '') - FROM search_rowid_map srm - JOIN files f ON f.blake3_hash = srm.blake3_hash - LEFT JOIN file_metadata m ON m.blake3_hash = srm.blake3_hash - WHERE srm.blake3_hash = NEW.blake3_hash LIMIT 1; -END; - --- Same trigger body for detach (UPDATE to file_tags.deleted_at). -CREATE TRIGGER IF NOT EXISTS search_after_file_tags_update -AFTER UPDATE ON file_tags BEGIN - INSERT INTO search_index (search_index, rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT 'delete', srm.rowid, '', '', '', '', '', '' - FROM search_rowid_map srm WHERE srm.blake3_hash = NEW.blake3_hash; - - INSERT INTO search_index (rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) - SELECT srm.rowid, - COALESCE( - CASE WHEN INSTR(f.relative_path, '/') > 0 - THEN SUBSTR(f.relative_path, LENGTH(f.relative_path) - INSTR(REVERSE(f.relative_path), '/') + 2) - ELSE f.relative_path END, ''), - f.relative_path, - COALESCE(m.mime_type, ''), - COALESCE(m.camera_model, ''), - COALESCE(m.captured_at, ''), - COALESCE((SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = NEW.blake3_hash AND ft.deleted_at IS NULL), '') - FROM search_rowid_map srm - JOIN files f ON f.blake3_hash = srm.blake3_hash - LEFT JOIN file_metadata m ON m.blake3_hash = srm.blake3_hash - WHERE srm.blake3_hash = NEW.blake3_hash LIMIT 1; -END; -``` - -### Rebuild Procedure - -`SqliteSearchRepository::rebuild(&self) -> Result<(), CoreError>` executes inside a `BEGIN IMMEDIATE` transaction: - -```sql -DELETE FROM search_rowid_map; -INSERT INTO search_index(search_index) VALUES('delete-all'); - -INSERT INTO search_rowid_map (blake3_hash, volume_id, relative_path) -SELECT DISTINCT f.blake3_hash, f.volume_id, f.relative_path FROM files f -WHERE f.deleted_at IS NULL; - -INSERT INTO search_index (rowid, filename, relative_path, mime_type, camera_model, captured_at, tags) -SELECT srm.rowid, - COALESCE( - CASE WHEN INSTR(f.relative_path, '/') > 0 - THEN SUBSTR(f.relative_path, LENGTH(f.relative_path) - INSTR(REVERSE(f.relative_path), '/') + 2) - ELSE f.relative_path END, ''), - f.relative_path, - COALESCE(m.mime_type, ''), - COALESCE(m.camera_model, ''), - COALESCE(m.captured_at, ''), - COALESCE((SELECT GROUP_CONCAT(t.name, ' ') - FROM file_tags ft JOIN tags t ON t.id = ft.tag_id - WHERE ft.blake3_hash = srm.blake3_hash AND ft.deleted_at IS NULL), '') -FROM search_rowid_map srm -JOIN files f ON f.blake3_hash = srm.blake3_hash -LEFT JOIN file_metadata m ON m.blake3_hash = srm.blake3_hash; -``` - ---- - -## 3. SearchRepository Port (perima-core) - -```rust -/// A single search hit. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchHit { - pub blake3_hash: String, - pub volume_id: String, - pub relative_path: String, - /// BM25 rank (lower is better, following SQLite convention). - pub rank: f64, -} - -pub trait SearchRepository: Send + Sync { - /// Run a FTS5 query and return ranked hits. - /// - /// `query` is passed directly to FTS5 as the match expression. - /// Callers must sanitize / validate before passing user input. - fn search(&self, query: &str, limit: u32) -> Result, CoreError>; - - /// Wipe and rebuild the entire FTS5 index from the current DB state. - /// - /// WHY: Needed after migrations that add new indexed fields, or to - /// recover from index corruption. Also exposed as `perima search --rebuild`. - fn rebuild(&self) -> Result<(), CoreError>; -} -``` - ---- - -## 4. CLI: `perima search ` - -``` -perima search [--limit ] [--json] [--rebuild] -``` - -- `--limit`: default 50, max 1000. -- `--json`: emit JSON array of `{ hash, volume_id, path, rank }`. -- `--rebuild`: rebuild index, print row count, exit (ignores ``). -- Normal output: table with HASH(8), PATH, RANK columns. - -Validation: reject empty query string before hitting FTS5. - ---- - -## 5. Tauri Command - -```rust -#[tauri::command] -async fn search( - query: String, - limit: Option, - state: State<'_, AppState>, -) -> Result, String> -``` - -`SearchHitPayload`: -```rust -pub struct SearchHitPayload { - pub hash: String, - pub volume_id: String, - pub relative_path: String, - pub rank: f64, -} -``` - -TS wrapper: `searchFiles(query: string, limit?: number): ResultAsync`. - ---- - -## 6. Frontend: SearchBar - -```tsx -interface SearchBarProps { - onResults: (hits: SearchHit[]) => void; - onClear: () => void; -} -``` - -- Debounced input (300 ms), min 2 chars to trigger. -- Calls `searchFiles(query, 100)`. -- On clear (empty input or × button), calls `onClear()`. -- App.tsx: if search results non-null, renders `FileTable` / `FileGrid` filtered - to matching hashes (preserves tag filter composition). -- Error surfaced via existing `error` state in App. - ---- - -## 7. Schema Rules Compliance - -FTS5 virtual tables do not have explicit primary keys or CRDT columns — -standard for virtual tables; FTS5 is not a mutable user-facing table. The -`search_rowid_map` side table holds no mutable user data (derived index); no -`updated_at`/`device_id` needed. The rebuild procedure ensures convergence. - ---- - -## 8. Known Limitations / Future Work - -- **Substring search**: FTS5's default unicode61 tokenizer supports prefix - queries (`vacat*`) but not infix. For infix, switch to the trigram tokenizer - (`tokenize="trigram"`). Trigram costs ~3× index size. Deferred post-v0.6. -- **Multi-location files**: `search_rowid_map` stores one row per (hash, - volume_id, relative_path). Files with identical content at multiple paths - each get their own FTS5 doc (same text, different path). This is intentional — - path is part of the document. -- **Delete sync**: files.deleted_at is not currently watched by a trigger. - The rebuild command is the safety net; a future trigger on files UPDATE - should delete the search_rowid_map row and FTS5 doc. -- **Ranking**: `bm25()` works out of the box. Custom weights per column - (filename > tags > mime_type) can be added with FTS5 column weights syntax - if needed post-v0.6. diff --git a/docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md b/docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md deleted file mode 100644 index f0bdf01..0000000 --- a/docs/superpowers/specs/2026-04-17-v0.6.2-live-faceted-search-design.md +++ /dev/null @@ -1,467 +0,0 @@ -# v0.6.2 — Live Faceted Search - -**Status:** draft awaiting subagent review -**Date:** 2026-04-17 -**Parent:** issue [#32](https://github.com/utof/perima/issues/32); closes [#25](https://github.com/utof/perima/issues/25) (search/tag composition bug) -**Phase:** 5b follow-up hotfix (not a new meta-plan phase) - ---- - -## Goal - -Replace the current drill-down search UX (v0.6.1) with live inline -narrowing + faceted tag panel. When the user types in the search bar: - -1. The file list narrows in place to the intersection of search matches - and any active tag filter. -2. The list re-sorts by BM25 rank while a search is active; the prior - sort restores when the search clears. -3. The sidebar (currently `TagSidebar` showing all tags) transforms into - a facet panel showing only tags present in the visible results, with - live counts. When search clears, the sidebar reverts to the full tag - list. -4. Clicking a sidebar/facet tag AND-composes with the search — - `visibleFiles = files.filter(matches-search AND has-tag)`. Clicking - again toggles the tag filter off. -5. Escape (with search input focused) clears the input. Full escape - stack deferred to [#28](https://github.com/utof/perima/issues/28). - -This closes the v0.6.1 regression (#25) by shipping the UX that the -original plan prescribed, but with live narrowing instead of a -redundant dropdown preview. - -## Non-goals (explicit deferrals) - -- **Full escape stack** (pop filters / detail view / tags). Scope of - keyboard-registry issue #28. -- **Multi-tag facet OR** (`search AND (tag:a OR tag:b)`). Listed as - post-v1 in the #32 issue body. -- **Query DSL** (`tag:foo kind:image free-text`). Meta-plan §Phase 5b - mentions this for later; v0.6.2 ships plain-text + phrases + prefix - wildcards only. -- **Facet counts across the full corpus.** Counts reflect the visible - result set only (client-side compute from the loaded `files` array). - Tracked as a follow-up issue (file at implementation time). -- **List cap pagination.** The 100-row cap on `listFilesWithTags` is - an existing limitation, not introduced here. Files outside the cap - are not surfaced by search. Tracked as a separate follow-up issue. -- **New backend Tauri commands.** All composition happens client-side - on already-loaded `FileWithTags[]`. -- **Dropdown preview of ranked top-N.** Removed entirely; list itself - is the result view. -- **Three-pane file-detail view.** Scope of #27. - ---- - -## Architecture - -### Data flow - -``` -user types "sunset" - │ - ▼ -SearchBar (debounced 300ms) - │ - ├── buildFtsQuery(raw) → sanitized FTS5 string - │ - ▼ -api.search(sanitized, limit=500) - │ - ▼ -Vec (BM25 rank included) - │ - ▼ -App.tsx state: { searchQuery, searchHits, searchActive } - │ - ▼ -composeVisible(files, selectedTagId, searchHits) - │ — if search active: filter by hash ∩ tag, sort by rank - │ — else: filter by tag only, honor existing sort - ▼ -visibleFiles: FileWithTags[] - │ - ▼ -computeFacets(visibleFiles) → Record - │ - ▼ -TagSidebar (mode=all | facets) -FileTable / FileGrid render visibleFiles -``` - -### State (owned by `App.tsx`) - -```typescript -// Existing (unchanged): -files: FileWithTags[] // from listFilesWithTags(100) -tags: Tag[] // from listTags() -selectedTagId: string | null // "All" = null -viewMode: "table" | "grid" -loading: boolean -scanResult, error, watcherError, scanning // (unchanged) - -// REMOVED: -searchHash: string | null // was v0.6.1's drill-down hack - -// NEW: -searchQuery: string // raw user input, lifted from SearchBar -searchHits: Set | null // blake3-hash set of matching files; - // null = no search active (show all / tag-filter only) - // empty Set = search active but zero matches (show empty list) - // Set (not array) for O(1) intersection. -hitRanks: Map // hash → BM25 rank; populated alongside searchHits. - // Used by sortByRank; new Map() when cleared. - -// DERIVED (not state; recomputed each render): -visibleFiles: FileWithTags[] // composeVisible(files, selectedTagId, searchHits) -facetCounts: Record // computeFacets(visibleFiles) -searchActive: boolean // searchHits !== null - // (the MIN_QUERY_LEN=2 guard lives in SearchBar; by the - // time App sees a non-null searchHits, the guard passed) -``` - -### Components - -#### `apps/desktop/src/lib/search.ts` (NEW) - -Pure functions, no React imports, easy to unit-test: - -```typescript -/** Sanitize raw user input into an FTS5-safe query string. */ -export function buildFtsQuery(raw: string): string; - -/** Compute tag id → count over the currently-visible file set. */ -export function computeFacets(files: FileWithTags[]): Record; - -/** Compose the visible file set from base list + tag filter + search hits. */ -export function composeVisible( - files: FileWithTags[], - selectedTagId: string | null, - searchHits: Set | null, -): FileWithTags[]; - -/** Sort visible files by BM25 rank (ascending = better match first). */ -export function sortByRank( - files: FileWithTags[], - hitRanks: Map, -): FileWithTags[]; -``` - -**`buildFtsQuery` sanitizer rules:** -- Trim input. -- If the whole input is already wrapped in double-quotes → pass through - verbatim (phrase query). -- If the input ends with `*` → treat as prefix query, split tokens, - wrap last token as `"prefix"*`. Example: `sunse*` → `"sunse"*`. -- Otherwise: split on whitespace, quote each non-empty token, join with - space (FTS5 implicit AND). - - `sunset photos` → `"sunset" "photos"` - - `it's fine` → `"it's" "fine"` (apostrophes safe inside quotes) - - `file/path.ext` → `"file/path.ext"` (slashes + dots OK in quotes) -- Strip any characters that remain parse-unsafe BEFORE tokenisation: - bare `"` (unpaired), raw `(` / `)`, and leading `-` on a token - (FTS5 negation). Then tokenise and quote as above. - - Honest subset: phrase + prefix + implicit AND. Everything else - is quoted away. - - Example: `(foo OR bar)` → parens stripped → tokens `foo`, `OR`, - `bar` → output `"foo" "OR" "bar"` (three implicit-AND tokens; - FTS5 OR keyword is lost, but input doesn't parse-error). - -**`computeFacets` reducer:** -```typescript -export function computeFacets(files: FileWithTags[]): Record { - const counts: Record = {}; - for (const f of files) { - for (const t of f.tags) { - counts[t.id] = (counts[t.id] ?? 0) + 1; - } - } - return counts; -} -``` - -**`composeVisible` logic:** -```typescript -export function composeVisible( - files: FileWithTags[], - selectedTagId: string | null, - searchHits: Set | null, -): FileWithTags[] { - return files.filter((f) => { - // Tag filter (if active): file must carry the tag. - if (selectedTagId !== null && !f.tags.some((t) => t.id === selectedTagId)) { - return false; - } - // Search filter (if active): file's hash must be in the hit set. - if (searchHits !== null && !searchHits.has(f.hash)) { - return false; - } - return true; - }); -} -``` - -#### `apps/desktop/src/components/SearchBar.tsx` (MODIFIED) - -**Changes from v0.6.1:** -- Remove dropdown render (the `
` block entirely). -- Remove local `hits: SearchHit[] | null`, `open: boolean`, and - `searching: boolean` state. -- Remove `containerRef` + the outside-click `useEffect` that closed - the dropdown. -- Remove `handleHitClick` and `onResultClick` prop. -- Keep: debounce, MIN_QUERY_LEN=2 guard, clear-button, error swallow. -- New prop: `onQueryChange(query: string, hits: SearchHit[] | null)`. - App.tsx uses this to lift `searchQuery` + the hash-set derived from - `hits` into App state. -- **SearchBar still calls `api.search(buildFtsQuery(raw), 500)` - internally** — the sanitizer + IPC stay co-located with the input. - After the debounce resolves, SearchBar calls - `onQueryChange(raw, hits)` to lift the result upward. (Limit raised - from 50 → 500 to feed the in-list re-sort.) -- **Three fire-cases for `onQueryChange`:** - 1. User typed ≥ MIN_QUERY_LEN and debounce resolved → - `onQueryChange(raw, hits)` (hits may be `[]` if nothing matched). - 2. User typed < MIN_QUERY_LEN (1 char, or cleared to empty) → - `onQueryChange("", null)` so App resets `searchHits` to null. - 3. User clicked clear button → `onQueryChange("", null)`. - -```typescript -interface SearchBarProps { - /** Fires whenever the debounced query resolves (with hits) or clears. */ - onQueryChange: (query: string, hits: SearchHit[] | null) => void; -} -``` - -#### `apps/desktop/src/components/TagSidebar.tsx` (MODIFIED) - -**Current props:** -```typescript -interface TagSidebarProps { - tags: Tag[]; - counts: Record; - totalCount: number; - selectedTagId: string | null; - onSelect: (id: string | null) => void; -} -``` - -**New props (add `mode`):** -```typescript -interface TagSidebarProps { - tags: Tag[]; - counts: Record; - totalCount: number; - selectedTagId: string | null; - onSelect: (id: string | null) => void; - /** - * "all" (default) = show every tag in `tags`. - * "facets" = show only tags whose count > 0 (i.e., present in current - * visible results). Switches automatically when a search is active. - */ - mode: "all" | "facets"; -} -``` - -**Render logic:** -- Mode `all`: existing behavior, unchanged. -- Mode `facets`: filter `tags` to `tags.filter(t => (counts[t.id] ?? 0) > 0)` before mapping. - - If the filtered list is empty: render a subtle empty-state row - ("No tags in current results") below the "All" row. -- "All" row count in facets mode: shows the total number of *visible* - files (= sum of counts), not `totalCount` (= all loaded). This - prevents the All row from lying ("All 100") when current facet view - has 12 files. - -#### `apps/desktop/src/App.tsx` (MODIFIED) - -**State changes:** -- Remove `searchHash: string | null` (line 52 of current App.tsx). -- Remove `handleSearchResult` function (lines 179–182 of current - App.tsx) — it's the exact source of #25 (`setSelectedTagId(null)`). -- Remove the `✕ search` badge JSX block from the header (lines - 190–199 of current App.tsx); the SearchBar's own clear button - replaces it. -- Add `searchQuery: string`, `searchHits: Set | null`, and - `hitRanks: Map` state (all three initialized as - `""`, `null`, `new Map()`). - -**Effect on mount (unchanged):** `listFilesWithTags(100)` + `listTags()`. - -**Handler (new):** -```typescript -function handleSearchChange(query: string, hits: SearchHit[] | null) { - setSearchQuery(query); - if (hits === null) { - setSearchHits(null); - setHitRanks(new Map()); - } else { - setSearchHits(new Set(hits.map((h) => h.blake3_hash))); - setHitRanks(new Map(hits.map((h) => [h.blake3_hash, h.rank]))); - } -} -``` - -**Derived (recomputed each render):** -```typescript -const searchActive = searchHits !== null; -const baseVisible = composeVisible(files, selectedTagId, searchHits); -const visibleFiles = searchActive - ? sortByRank(baseVisible, hitRanks) - : baseVisible; -const facetCounts = computeFacets(visibleFiles); -const sidebarMode: "all" | "facets" = searchActive ? "facets" : "all"; -const sidebarTotalCount = searchActive ? visibleFiles.length : files.length; -``` - -**Header JSX change:** -- Replace `` with - ``. -- (The `✕ search` badge removal was already listed above under State - changes.) - -**Sidebar JSX change:** -- `` - ---- - -## Error handling - -- **FTS5 parse error** (query contains something the sanitizer misses): - existing SearchBar `.match()` error branch swallows silently → - `setHits([])`. User sees an empty list; no red banner. Matches - existing behavior. -- **Backend unreachable / Tauri IPC fails**: same swallow path. A more - user-visible error surface is tracked in a separate issue (not blocker - for v0.6.2). -- **Corrupt `FileWithTags` payload** (tags is not an array, etc.): - `composeVisible` and `computeFacets` use strict `.some`/`.forEach` - which will throw on non-array `tags`. Acceptable: backend contract - guarantees the shape; if it breaks, fail loudly. - ---- - -## Testing - -### Unit tests — `lib/search.ts` (NEW file: `lib/__tests__/search.test.ts`) - -- `buildFtsQuery`: - - plain input `sunset photos` → `"sunset" "photos"` - - explicit phrase `"blue ridge"` → `"blue ridge"` - - prefix `sunse*` → `"sunse"*` - - input with apostrophe `it's` → `"it's"` - - input with slash/dot `a/b.jpg` → `"a/b.jpg"` - - empty / whitespace-only → `""` (SearchBar's MIN_QUERY_LEN guard - blocks upstream; test anyway) - - adversarial: bare `"` stripped → empty output; `(foo OR bar)` → - parens stripped → `"foo" "OR" "bar"` (OR keyword becomes a - literal token; documented limitation) -- `computeFacets`: - - empty files → `{}` - - file with no tags → `{}` - - 3 files all tagged `vacation` → `{ "vacation-id": 3 }` - - 2 files tagged (vacation, sunset), 1 file tagged (vacation) → - `{ "vacation-id": 3, "sunset-id": 2 }` -- `composeVisible`: - - no filters → returns `files` verbatim - - tag filter only → returns tag-matching subset - - search hits only → returns hit-matching subset - - both → returns the intersection - - search hits empty set → returns `[]` - - tag filter set to unknown id → returns `[]` -- `sortByRank`: - - rank -2.0 ranks before rank -1.0 (lower = better) - - files not in hitRanks are kept at the end (shouldn't happen in - practice since `composeVisible` filters first, but defend) - -### Component tests — `apps/desktop/src/__tests__/` - -- `SearchBar.test.tsx` (MODIFIED): - - Existing tests: renders input, clears on ✕, debounce timing, - empty-results path, 1-char skipped, 2-char fires — all keep. - - Remove: click-a-hit test, dropdown-visible test. - - New: `fires onQueryChange with null hits when cleared`. - - New: `fires onQueryChange with (query, hits) after debounce`. -- `TagSidebar.test.tsx` (MODIFIED): - - New: `mode="facets" hides tags with 0 counts`. - - New: `mode="facets" shows empty-state row when all counts are 0`. - - New: `mode="facets" All row count reflects sum of counts`. - - Existing tests still pass with `mode="all"` default. -- `App.test.tsx` (MODIFIED): - - Existing file-event debounce test: already correct in current code - (mock expectations updated in `cd55627`); no change needed. - - Existing watcher-banner test: keep as-is. -- `App.compose.test.tsx` (NEW): **composition snapshot test**. Given - fixture inputs (files, tagId, hits), assert visibleFiles matches an - expected snapshot. Pins the exact bug that shipped in v0.6.1. Five - cases: - 1. No search, no tag → all files. - 2. Tag filter only → tag-narrowed. - 3. Search only → hit-narrowed, sorted by rank. - 4. Search + tag → intersected, sorted by rank (the invariant #25 broke). - 5. Search active but zero hits (`searchHits = new Set()`) → empty - visible list, sidebarMode = "facets", facetCounts = `{}` - (empty-state branch). Distinct from case 1 (`searchHits = null`, - sidebarMode = "all"). - -### No Playwright; no Tauri IPC integration test changes (search command's `search_inner` smoke test already exists from I8 fix). - ---- - -## Commit / release plan - -- **1 feat commit:** `feat(desktop): live faceted search with tag composition (#32)` - - Changes all described files. - - Body must include: `headless-tested: yes (vitest component + composition snapshot; no Tauri runtime)`. - - Body lists alternatives considered (Approaches 1 / 2 / 3 from brainstorm). -- **1 test commit:** `test(desktop): unit tests for search query sanitizer + compose reducer` - - Adds `lib/__tests__/search.test.ts` + `App.compose.test.tsx`. - - Can be bundled into the feat commit if diff is small; current estimate is ~200 lines of tests so separate is cleaner. -- **1 release commit:** `chore(release): v0.6.2 — live faceted search + composition fix (#25)` - - Bumps workspace version 0.6.1 → 0.6.2 in Cargo.toml, apps/desktop/package.json. - - CHANGELOG.md `[0.6.2]` entry under `### Fixed` (for #25) + `### Changed` (for search UX). - - Release-plz auto-tags on push. - -Two-stage review per binding rules (#26): spec-compliance reviewer + -code-quality reviewer dispatched after the feat commit; fixes go in -separate `fix(desktop): ...` commits if needed. - ---- - -## Risks - -- **Client-side facet counts understate for libraries > 100 files.** - Accepted per scope; follow-up issue tracks the full-corpus option. -- **Query sanitizer is an approximation.** Users who want advanced FTS5 - features (NEAR, column filters) won't get them. Post-v1 query DSL - picks this up. -- **Removing the dropdown changes keyboard focus flow.** Users - previously `Tab` into the search input then arrow-down into the - dropdown; now `Tab` goes into the list. Accepted — better keyboard - flow lands with #28. -- **Escape only clearing input (v0.6.2) will feel inconsistent when - the detail view lands** (#27). Explicit v0.6.2 limitation; #28 fixes - it holistically. -- **Stale search hits after file-watcher refresh.** `searchHits` is - not automatically re-queried when the watcher fires a list refresh - (the existing 300ms debounced `listFilesWithTags(100)` reload). - After a watcher-triggered reload, if the user was searching, their - `searchHits` set still reflects the pre-reload corpus — so new - files matching the query won't appear, and deleted files will be - filtered out by the missing hash. User-visible as "the list isn't - updating even though I know I just added a file." Mitigation for - v0.6.2: documented; user can retype to re-fire. Post-v1 fix: the - file-event effect in App.tsx re-calls `buildFtsQuery` + `api.search` - alongside the `listFilesWithTags` refresh when a search is active. - -## Decisions and why - -- **Q1 sidebar transforms (B)** over separate facet panel (A) or dimmed full list (C) — single mental model, matches Linear / GitHub Issues conventions. -- **Q2 no dropdown + in-list re-sort by rank (C)** over dropdown removal without re-sort (A) or dropdown-plus-list (B) — preserves BM25 rank signal without duplicate surface. -- **Q3 AND-compose with toggle (A)** over replace (B) or multi-facet OR (C) — matches faceted search conventions; multi-OR is post-v1 per issue body. -- **Q4 client-side facet counts (A)** over dedicated RPC (B) or hybrid-with-disclaimer (C) — zero new backend work; honest within the 100-row cap which is a pre-existing constraint. -- **Q5 safe-subset sanitizer (C)** over plain-text-only (A) or pass-through (B) — graceful with apostrophes/slashes while keeping phrase + prefix; pass-through currently swallows real parse errors silently. -- **Q6 no backend change (A)** over new compose command (B) or extended `search` payload (C) — cap is a cross-cutting issue; fix once later. -- **Q7 minimal escape (C)** over 2-layer escape (B) or native-only (A) — avoid half-implementing a pattern that #28 will redesign. -- **Q8 unit + composition snapshot (C)** over unit-only (A) or Playwright (B) — snapshot pins the exact class of bug that shipped in v0.6.1; Playwright is meta-plan-post-v1. -- **Approach 1 (minimal, no hook extraction)** over useFilteredFiles hook (2) or zustand (3) — YAGNI until #28 adds a second consumer of the filter state. diff --git a/justfile b/justfile index 883dc05..fb8af8e 100644 --- a/justfile +++ b/justfile @@ -64,6 +64,14 @@ bindings: cargo build -p perima-desktop --features specta-export git diff --exit-code apps/desktop/src/bindings.ts +# Fetch the ffmpeg static binary used by perima-desktop's externalBin +# sidecar slot. Required before any `cargo build/clippy/test` of +# `perima-desktop`: tauri-build validates externalBin paths during every +# compile (issue tauri-apps/tauri#14602). Linux ships the real binary; +# macOS + Windows write a stub until T12 follow-up issues land. +sidecar: + ./scripts/fetch-ffmpeg-sidecar.sh + deny: cargo deny check diff --git a/scripts/fetch-ffmpeg-sidecar.sh b/scripts/fetch-ffmpeg-sidecar.sh new file mode 100755 index 0000000..f4c6efa --- /dev/null +++ b/scripts/fetch-ffmpeg-sidecar.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Fetch the ffmpeg static binary used by perima-desktop's externalBin +# sidecar slot. +# +# WHY this script (T12, Linux v1): +# tauri-build validates `bundle.externalBin` paths during EVERY cargo +# compile of `perima-desktop` (not just `tauri build`). With +# `externalBin = ["binaries/ffmpeg"]` declared in tauri.conf.json, +# tauri-build looks for `crates/desktop/binaries/ffmpeg-{target-triple}` +# and fails the build if the file is absent. Since the binary is +# ~80 MB and platform-specific, it is gitignored — every dev machine +# and every CI runner provisions it via this script before any cargo +# command touches `perima-desktop`. +# +# WHY johnvansickle.com over `ffmpeg-sidecar`'s built-in downloader: +# The Rust crate's `auto_download` defaults to "latest" build URLs +# that can change underfoot; pinning a stable johnvansickle release +# (BtbN's evermeet equivalent) keeps CI reproducible across reruns. +# Their static builds are widely used in Tauri sidecar deployments +# (referenced in `ffmpeg-sidecar`'s README + multiple Tauri tutorials). +# +# WHY direct `curl` + `tar` over a Rust helper: +# Avoids a chicken-and-egg problem — fetching ffmpeg via `cargo run` +# would compile perima-desktop first, which fails without the file. +# +# Usage: +# scripts/fetch-ffmpeg-sidecar.sh +# +# Linux only in v1. macOS + Windows tracked as T12 follow-up issues. + +set -euo pipefail + +# Resolve the repo root from the script location so `just sidecar` and +# direct invocations both work regardless of CWD. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +sidecar_dir="$repo_root/crates/desktop/binaries" + +mkdir -p "$sidecar_dir" + +# Map host OS -> ({target-triple}, {fetch_command}). +case "$(uname -s)" in + Linux) + target_triple="x86_64-unknown-linux-gnu" + out_path="$sidecar_dir/ffmpeg-$target_triple" + if [[ -x "$out_path" ]]; then + echo "ffmpeg sidecar already present at $out_path; skipping fetch." + exit 0 + fi + # WHY a mirror list rather than one URL: johnvansickle.com is a + # single small host, and CI hits it 4x per run (3-platform matrix + + # bindings-drift). Observed 2026-08-01, in escalating severity + # within one hour: first a rate-limit page served with HTTP 200 + # (see the size gate below), then outright connection timeouts — + # `curl: (28) Failed to connect ... after 30002 ms`. Retrying cannot + # fix a host that has stopped accepting the connection, so the fetch + # needs a second source that does not throttle GitHub Actions egress. + # + # Order: johnvansickle first (41 MB, smallest download), then BtbN's + # FFmpeg-Builds on GitHub Releases (127 MB, served from GitHub's own + # CDN — the one host Actions runners can always reach). Both are GPL + # static builds, so this is a like-for-like fallback with no change + # to the licensing posture of what gets bundled. + urls=( + "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" + ) + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + archive="$tmp_dir/ffmpeg.tar.xz" + + # WHY a hand-rolled retry loop instead of just `curl --retry`: + # CI fetches this tarball from 4 jobs (3-platform matrix + + # bindings-drift). johnvansickle.com throttles bursts from a single + # GitHub Actions egress range, and it does so by returning a SHORT + # HTML notice with HTTP status 200 — not a 4xx/5xx. `curl --retry` + # and `--fail` both key off the status code, so curl reports success + # and the truncated body only explodes later inside tar as + # "xz: (stdin): File format not recognized" — an opaque message that + # reads like archive corruption rather than a throttled download. + # Observed 2026-08-01: the matrix ubuntu job fetched 41 MB fine while + # bindings-drift got a 0.3-second "download" minutes later. + # So: validate the payload ourselves, and treat a too-small body as a + # retryable condition. + min_bytes=10000000 # smallest real tarball is ~41 MB; less is a server message + attempts_per_url=3 + fetched=0 + + for url in "${urls[@]}"; do + attempt=1 + while (( attempt <= attempts_per_url )); do + echo "Downloading ffmpeg from $url (attempt $attempt/$attempts_per_url) ..." + rm -f "$archive" + # `|| true` so a curl-level failure (connect timeout, 4xx) falls + # through to the same size check + backoff path instead of + # tripping `set -e` and skipping the remaining mirrors. + curl --fail --location --silent --show-error \ + --connect-timeout 20 --max-time 600 --output "$archive" "$url" || true + + archive_bytes=0 + [[ -f "$archive" ]] && archive_bytes="$(wc -c < "$archive")" + + if (( archive_bytes >= min_bytes )); then + fetched=1 + break + fi + + echo "WARNING: got ${archive_bytes} bytes, expected >= ${min_bytes}." >&2 + if [[ -s "$archive" ]]; then + echo " First bytes of the response:" >&2 + head -c 200 "$archive" >&2 || true + echo >&2 + fi + + backoff=$(( attempt * 5 )) + echo " Retrying in ${backoff}s ..." >&2 + sleep "$backoff" + attempt=$(( attempt + 1 )) + done + + # WHY `if` and not `(( fetched )) && break`: under `set -e` an + # AND-list whose first command fails takes down the script. + if (( fetched )); then + break + fi + echo "NOTICE: $url exhausted; falling through to the next mirror." >&2 + done + + if (( ! fetched )); then + echo "ERROR: no mirror served the ffmpeg tarball." >&2 + echo " Tried: ${urls[*]}" >&2 + echo " See GH #183 (sha256-pin + mirror consolidation)." >&2 + exit 1 + fi + + echo "Extracting ($archive_bytes bytes) ..." + tar -xJf "$archive" -C "$tmp_dir" + # WHY maxdepth 4: the mirrors nest differently — johnvansickle + # unpacks to /ffmpeg (depth 2), BtbN to /bin/ffmpeg + # (depth 3). A depth-2 search silently finds nothing on BtbN. + extracted_bin="$(find "$tmp_dir" -maxdepth 4 -type f -name ffmpeg | head -n1)" + if [[ -z "$extracted_bin" ]]; then + echo "ERROR: could not find ffmpeg binary in extracted tarball" >&2 + exit 1 + fi + cp "$extracted_bin" "$out_path" + chmod +x "$out_path" + echo "Installed: $out_path" + ;; + Darwin) + # TODO(T12-followup): macOS sidecar fetch (e.g. evermeet.cx static + # build) — file an issue at plan-merge. For now, drop a stub so + # `cargo build -p perima-desktop` succeeds; runtime invocation + # surfaces a clear error from the audio pipeline. + target_triple="$(rustc --print host-tuple 2>/dev/null || echo x86_64-apple-darwin)" + out_path="$sidecar_dir/ffmpeg-$target_triple" + echo "WARNING: macOS sidecar fetch not implemented (T12-followup);" >&2 + echo " writing stub at $out_path so tauri-build passes." >&2 + : > "$out_path" + chmod +x "$out_path" + ;; + MINGW*|MSYS*|CYGWIN*) + # TODO(T12-followup): Windows sidecar fetch (e.g. BtbN GitHub + # releases) — file an issue at plan-merge. + target_triple="$(rustc --print host-tuple 2>/dev/null || echo x86_64-pc-windows-msvc)" + out_path="$sidecar_dir/ffmpeg-$target_triple.exe" + echo "WARNING: Windows sidecar fetch not implemented (T12-followup);" >&2 + echo " writing stub at $out_path so tauri-build passes." >&2 + : > "$out_path" + ;; + *) + echo "ERROR: unsupported host OS: $(uname -s)" >&2 + exit 1 + ;; +esac diff --git a/scripts/test-precommit-hook.sh b/scripts/test-precommit-hook.sh old mode 100644 new mode 100755