- {summary}
+
+ {summary}
+
);
}
diff --git a/apps/desktop/src/hooks/useDomainEvents.ts b/apps/desktop/src/hooks/useDomainEvents.ts
index f410d81..1d4902c 100644
--- a/apps/desktop/src/hooks/useDomainEvents.ts
+++ b/apps/desktop/src/hooks/useDomainEvents.ts
@@ -22,6 +22,7 @@ import type { UnsubscribeFn } from "../api";
import { filesKeys } from "../queries/files";
import { tagsKeys } from "../queries/tags";
import { searchKeys } from "../queries/search";
+import { dedupKeys } from "../queries/dedup";
import { useUiStore } from "../stores/ui";
const FILES_DEBOUNCE_MS = 300;
@@ -29,6 +30,8 @@ const FILES_DEBOUNCE_MS = 300;
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);
useEffect(() => {
let active = true;
@@ -71,6 +74,11 @@ export function useDomainEvents(): void {
case "SearchIndexRebuilt":
void queryClient.invalidateQueries({ queryKey: searchKeys.all });
break;
+ case "CollisionsChanged":
+ // WHY: CollisionsChanged invalidates the dedup/collision-group
+ // query surface (Task 13). No query key exists yet — placeholder
+ // to silence the exhaustive-never check until Task 13 lands.
+ break;
default: {
const _exhaustive: never = event.data.reason;
throw new Error(
@@ -79,6 +87,26 @@ export function useDomainEvents(): void {
}
}
break;
+ case "VerifyProgress":
+ // WHY push into Zustand (not TanStack Query): progress is a
+ // transient push value — not server state that should be cached or
+ // refetched. Task 13 (`/dedup` route) reads the `verifyBatch` slice
+ // from the store to drive a progress bar without polling IPC.
+ setVerifyBatchProgress(
+ event.data.batch_id,
+ event.data.files_done,
+ event.data.files_total,
+ event.data.latest_outcome,
+ );
+ break;
+ case "VerifyComplete":
+ // WHY two actions on complete:
+ // 1. Reset the Zustand progress slice — batch is done.
+ // 2. Invalidate the collision-group query so the dedup table
+ // reflects the newly-computed full-hash data.
+ clearVerifyBatch();
+ void queryClient.invalidateQueries({ queryKey: dedupKeys.all });
+ break;
default: {
const _exhaustive: never = event;
throw new Error(`Unhandled AppEvent kind: ${JSON.stringify(_exhaustive)}`);
@@ -99,5 +127,5 @@ export function useDomainEvents(): void {
if (filesDebounceTimer) clearTimeout(filesDebounceTimer);
if (unsubscribe) unsubscribe();
};
- }, [queryClient, notifyError]);
+ }, [queryClient, notifyError, setVerifyBatchProgress, clearVerifyBatch]);
}
diff --git a/apps/desktop/src/lib/__tests__/fixtures.ts b/apps/desktop/src/lib/__tests__/fixtures.ts
index a834b12..46a315e 100644
--- a/apps/desktop/src/lib/__tests__/fixtures.ts
+++ b/apps/desktop/src/lib/__tests__/fixtures.ts
@@ -11,8 +11,14 @@ import type { FileWithTagsPayload } from "../../bindings";
* `tags[].id`) take real values; everything else is a neutral default.
*/
export function file(hash: string, tagIds: string[]): FileWithTagsPayload {
+ // WHY (Task 11): `file_uuid` is the stable surrogate (always present);
+ // `hash` is `string | null` post-spec-§4.8. Tests pass a non-null hash by
+ // default so the existing assertions still hold; new pending-file tests
+ // construct payloads inline to exercise the null branch.
return {
+ file_uuid: `uuid-${hash}`,
hash,
+ quick_hash: null,
size: 0,
volume_id: "vol",
relative_path: `${hash}.jpg`,
diff --git a/apps/desktop/src/lib/__tests__/search.test.ts b/apps/desktop/src/lib/__tests__/search.test.ts
index 2744741..de055c9 100644
--- a/apps/desktop/src/lib/__tests__/search.test.ts
+++ b/apps/desktop/src/lib/__tests__/search.test.ts
@@ -3,24 +3,34 @@ import { buildFtsQuery, computeFacets, composeVisible, sortByRank } from "../sea
import { file } from "./fixtures";
describe("buildFtsQuery", () => {
- it("quotes plain tokens with implicit AND", () => {
- expect(buildFtsQuery("sunset photos")).toBe('"sunset" "photos"');
+ // WHY assertions updated 2026-04-25: plain queries now auto-prefix the
+ // last token. Phrase-passthrough and explicit `*` paths unchanged.
+ it("quotes earlier tokens + auto-prefixes the last", () => {
+ expect(buildFtsQuery("sunset photos")).toBe('"sunset" "photos"*');
});
- it("passes explicit phrase queries verbatim", () => {
+ it("auto-prefixes a single token", () => {
+ expect(buildFtsQuery("mp4")).toBe('"mp4"*');
+ });
+
+ it("auto-prefixes a 2-character query (covers the 'Cl' → Claude case)", () => {
+ expect(buildFtsQuery("Cl")).toBe('"Cl"*');
+ });
+
+ it("passes explicit phrase queries verbatim (whole-token mode)", () => {
expect(buildFtsQuery('"blue ridge"')).toBe('"blue ridge"');
});
- it("wraps prefix queries with quoted-token + asterisk", () => {
+ it("wraps explicit 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 apostrophes inside quoted tokens (auto-prefixed)", () => {
+ 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("handles slashes and dots inside quoted tokens (auto-prefixed)", () => {
+ expect(buildFtsQuery("a/b.jpg")).toBe('"a/b.jpg"*');
});
it("returns empty string on whitespace-only input", () => {
@@ -28,20 +38,20 @@ describe("buildFtsQuery", () => {
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 parens and leading dashes before tokenising (auto-prefixed)", () => {
+ // (foo OR bar) → parens stripped → tokens foo, OR, bar; last gets *
+ 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"');
+ it("strips leading dash on a token (FTS5 negation hazard, auto-prefixed)", () => {
+ expect(buildFtsQuery("-foo")).toBe('"foo"*');
});
- it("wraps multi-token prefix queries with earlier tokens quoted + last token asterisked", () => {
+ it("respects explicit prefix on multi-token queries", () => {
expect(buildFtsQuery("foo bar*")).toBe('"foo" "bar"*');
});
});
diff --git a/apps/desktop/src/lib/coreError.ts b/apps/desktop/src/lib/coreError.ts
index da3aedd..66fef47 100644
--- a/apps/desktop/src/lib/coreError.ts
+++ b/apps/desktop/src/lib/coreError.ts
@@ -1,4 +1,4 @@
-import type { CoreError } from "../bindings";
+import type { CoreError, FullHashUnavailableReason } from "../bindings";
/**
* Returns a human-readable string from a {@link CoreError} data payload.
@@ -10,6 +10,9 @@ import type { CoreError } from "../bindings";
* (JSON.stringify throws on cyclic inputs — guarded by try/catch).
*/
export function coreErrorMessage(e: CoreError): string {
+ if (e.kind === "FullHashUnavailable") {
+ return fullHashUnavailableMessage(e.data.reason);
+ }
if (typeof e.data === "string") {
return e.data;
}
@@ -21,3 +24,14 @@ export function coreErrorMessage(e: CoreError): string {
return "[unserializable error data]";
}
}
+
+function fullHashUnavailableMessage(reason: FullHashUnavailableReason): string {
+ switch (reason.kind) {
+ case "NotMounted":
+ return `Full hash unavailable: volume not mounted (${reason.volume_id}).`;
+ case "NotComputed":
+ return "Full hash has not been computed for this file yet.";
+ case "IoError":
+ return `Full hash unavailable: I/O error (${reason.message}).`;
+ }
+}
diff --git a/apps/desktop/src/lib/search.ts b/apps/desktop/src/lib/search.ts
index 40b95b6..b58e115 100644
--- a/apps/desktop/src/lib/search.ts
+++ b/apps/desktop/src/lib/search.ts
@@ -56,10 +56,27 @@ export function buildFtsQuery(raw: string): string {
return prefix === "" ? suffix : `${prefix} ${suffix}`;
}
- // Plain tokens: strip unsafe, split, quote each.
+ // Plain tokens: strip unsafe, split, quote each, AND auto-suffix `*` to
+ // the last token so users get prefix-match by default.
+ //
+ // WHY auto-prefix on the last token (added 2026-04-25): FTS5's default
+ // is whole-token MATCH — typing "Cl" returned zero results even when
+ // many files contained "Claude" because there's no token literally
+ // equal to "cl". Native expectation in 2026 is "match as I type".
+ // Earlier tokens are kept as whole-token AND clauses (so "vacation
+ // sun" matches "vacation sunset" but also "vacation sundown" — the
+ // last-token-prefix rule, NOT every-token-prefix, balances recall
+ // against noise). Users who want exact whole-token can wrap in quotes
+ // (phrase passthrough); users who want explicit prefix can still type
+ // `foo*` (handled above).
const sanitized = stripUnsafe(trimmed);
const tokens = sanitized.split(/\s+/).filter((t) => t !== "");
- return tokens.map((t) => `"${t}"`).join(" ");
+ if (tokens.length === 0) return "";
+ // WHY: filter+length-guard above means tokens is non-empty here.
+ const last = tokens.pop()!;
+ const prefix = tokens.map((t) => `"${t}"`).join(" ");
+ const suffix = `"${last}"*`;
+ return prefix === "" ? suffix : `${prefix} ${suffix}`;
}
/** Strip FTS5-unsafe characters before tokenisation. */
@@ -125,7 +142,10 @@ export function composeVisible(
if (selectedTagId !== null && !f.tags.some((t) => t.id === selectedTagId)) {
return false;
}
- if (searchHits !== null && !searchHits.has(f.hash)) {
+ // WHY (Task 11): `f.hash` is `string | null` post-spec-§4.8 sweep.
+ // Pending files have no hash so they cannot match a hash-keyed
+ // FTS hit set; they're filtered out when search is active.
+ if (searchHits !== null && (f.hash === null || !searchHits.has(f.hash))) {
return false;
}
return true;
@@ -146,7 +166,9 @@ export function sortByRank(
const ranked: Array<{ file: FileWithTagsPayload; rank: number }> = [];
const unranked: FileWithTagsPayload[] = [];
for (const f of files) {
- const r = hitRanks.get(f.hash);
+ // WHY (Task 11): pending files (`f.hash === null`) cannot be looked up
+ // in the hash-keyed `hitRanks` map; they fall through to `unranked`.
+ const r = f.hash === null ? undefined : hitRanks.get(f.hash);
if (r === undefined) unranked.push(f);
else ranked.push({ file: f, rank: r });
}
diff --git a/apps/desktop/src/queries/dedup.ts b/apps/desktop/src/queries/dedup.ts
new file mode 100644
index 0000000..02ecaec
--- /dev/null
+++ b/apps/desktop/src/queries/dedup.ts
@@ -0,0 +1,144 @@
+/**
+ * Dedup query key namespace and hooks.
+ *
+ * WHY "dedup" root key: matches the domain name (`DedupUseCase`) and avoids
+ * collision with the "files" root used by `filesKeys` (even though collisions
+ * are file-adjacent data, their lifecycle differs — they are refreshed on
+ * `VerifyComplete` / `IndexInvalidated::CollisionsChanged`, not on generic
+ * `IndexInvalidated::FilesChanged`).
+ */
+import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import * as api from "../api";
+import type { BatchId, FileUuid } from "../bindings";
+import { filesKeys } from "./files";
+
+export const dedupKeys = {
+ /** Root key — invalidate to bust all dedup-related queries. */
+ all: ["dedup"] as const,
+ /** Collision-group list. */
+ collisions: () => [...dedupKeys.all, "collisions"] as const,
+} as const;
+
+/**
+ * Query options factory for the collision-group list.
+ *
+ * WHY a separate factory: allows consumers to pre-fetch or share the same
+ * query without importing the hook (e.g. route loaders in Task 13).
+ */
+export function collisionsQueryOptions() {
+ return queryOptions({
+ queryKey: dedupKeys.collisions(),
+ queryFn: () =>
+ api.listQuickHashCollisions().match(
+ (data) => data,
+ // WHY eslint-disable: TanStack Query queryFn accepts any thrown value;
+ // CoreError is the registered defaultError type (queryClient.ts Register
+ // augmentation) so useCollisions().error is typed as CoreError | null.
+ // Wrapping in Error would lose the typed discriminant.
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ });
+}
+
+/**
+ * Subscribe to the collision-group list query.
+ *
+ * WHY no staleTime override: the global queryClient default (5 min) is
+ * appropriate — collisions change only when a scan or verify completes,
+ * both of which emit `IndexInvalidated::CollisionsChanged` / `VerifyComplete`
+ * that trigger a manual invalidation via `useDomainEvents`.
+ */
+export function useCollisions() {
+ return useQuery(collisionsQueryOptions());
+}
+
+/**
+ * Mutation: compute the canonical `full_hash` for a single file.
+ *
+ * Synchronous on the IPC surface — the mutation resolves only once the
+ * writer has persisted the hash. Invalidates `filesKeys.all` (so a
+ * pending file's hash column refreshes) and `dedupKeys.all` (so the
+ * collision-group list reflects the newly-known full hash).
+ */
+export function useComputeFullHash() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { fileUuid: FileUuid }) =>
+ api.computeFullHash(vars.fileUuid).match(
+ (hash) => hash,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: filesKeys.all });
+ void qc.invalidateQueries({ queryKey: dedupKeys.all });
+ },
+ });
+}
+
+/**
+ * Mutation: spawn a batch full-hash compute over the given `file_uuids`.
+ *
+ * The `BatchHandle` resolves immediately; progress arrives via the
+ * AppEvent channel and is mirrored into the Zustand `verifyBatch` slice
+ * by `useDomainEvents`. The dedup query gets invalidated on the
+ * subsequent `VerifyComplete` event (also in `useDomainEvents`); we
+ * additionally fire a coarse `dedupKeys.all` invalidate on `onSuccess`
+ * so the UI reacts even if a future spec change drops `VerifyComplete`.
+ */
+export function useVerifyBatch() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { fileUuids: FileUuid[] }) =>
+ api.computeFullHashBatch(vars.fileUuids).match(
+ (handle) => handle,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: dedupKeys.all });
+ },
+ });
+}
+
+/**
+ * Mutation: cancel an in-flight verify batch by `batch_id`.
+ *
+ * Treats `CoreError::NotFound` as success — the batch finished before
+ * cancel raced through. The Zustand slice is cleared by the
+ * `VerifyComplete` event handler (or the writer's terminal flush);
+ * the mutation does not touch the slice directly.
+ */
+export function useCancelVerifyBatch() {
+ return useMutation({
+ mutationFn: (vars: { batchId: BatchId }) =>
+ api.cancelVerifyBatch(vars.batchId).match(
+ (v) => v,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ });
+}
+
+/**
+ * Mutation: memorise that the supplied `file_uuids` were verified to have
+ * distinct `full_hash` values despite sharing a `quick_hash`.
+ *
+ * Invalidates `dedupKeys.all` so the verified-distinct group disappears
+ * from the candidate list.
+ */
+export function useMarkVerifiedDistinct() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { fileUuids: FileUuid[] }) =>
+ api.markVerifiedDistinct(vars.fileUuids).match(
+ (v) => v,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: dedupKeys.all });
+ },
+ });
+}
diff --git a/apps/desktop/src/queries/search.ts b/apps/desktop/src/queries/search.ts
index d070357..ec44a39 100644
--- a/apps/desktop/src/queries/search.ts
+++ b/apps/desktop/src/queries/search.ts
@@ -23,7 +23,12 @@ export const searchKeys = {
[...searchKeys.all, "query", { q, limit }] as const,
} as const;
-export function searchQueryOptions(query: string, limit = 50) {
+// WHY default 500 (was 50): paired with useFiles(1000) above; with limit=50
+// the intersection silently dropped most matches in libraries >50 results
+// (e.g. searching "mp4" in a 340-mp4 library returned 3 visible). 500 is
+// generous for a single search page; full-corpus pagination is a separate
+// effort tracked alongside virtualisation.
+export function searchQueryOptions(query: string, limit = 500) {
return queryOptions({
queryKey: searchKeys.query(query, limit),
queryFn: () =>
diff --git a/apps/desktop/src/queries/tags.ts b/apps/desktop/src/queries/tags.ts
index bc6d514..18d4562 100644
--- a/apps/desktop/src/queries/tags.ts
+++ b/apps/desktop/src/queries/tags.ts
@@ -10,8 +10,9 @@
* the thrown `CoreError` as `error`. Do NOT use `.unwrapOr(...)` which silently
* swallows errors.
*/
-import { queryOptions, useQuery } from "@tanstack/react-query";
+import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../api";
+import { filesKeys } from "./files";
export const tagsKeys = {
all: ["tags"] as const,
@@ -37,3 +38,92 @@ export function tagsQueryOptions() {
export function useTags() {
return useQuery(tagsQueryOptions());
}
+
+/**
+ * Mutation: attach a tag to a file by hash + tag name.
+ *
+ * On success, invalidates the files list and the tags list so the new tag
+ * (or new attachment) shows up everywhere it's rendered. Errors propagate
+ * as `CoreError` via the registered defaultError type.
+ *
+ * WHY useMutation (not raw api call): TanStack Query handles the
+ * pending/success/error UI state + the cache-invalidation atomically,
+ * keeping the `useDomainEvents` event-driven invalidation as a fallback
+ * (events fire after the writer commits; the optimistic invalidate
+ * here is just a UX nicety to refresh sooner than the round-trip).
+ */
+export function useAttachTag() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { hash: string; tagName: string }) =>
+ api.attachTag(vars.hash, vars.tagName).match(
+ (tag) => tag,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: filesKeys.all });
+ void qc.invalidateQueries({ queryKey: tagsKeys.all });
+ },
+ });
+}
+
+/**
+ * Mutation: detach a tag from a file by hash + tag id.
+ *
+ * Symmetric to `useAttachTag`. Same invalidation strategy.
+ */
+export function useDetachTag() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { hash: string; tagId: string }) =>
+ api.detachTag(vars.hash, vars.tagId).match(
+ (v) => v,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: filesKeys.all });
+ void qc.invalidateQueries({ queryKey: tagsKeys.all });
+ },
+ });
+}
+
+/**
+ * Mutation: attach a tag by `file_uuid` (stable surrogate) instead of `hash`.
+ *
+ * Task 11 (spec §4.8): pending files (no `full_hash` yet) must still be
+ * taggable. The frontend prefers this hook when `f.hash` is `null`.
+ */
+export function useAttachTagByUuid() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { fileUuid: string; tagName: string }) =>
+ api.attachTagByUuid(vars.fileUuid, vars.tagName).match(
+ (tag) => tag,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: filesKeys.all });
+ void qc.invalidateQueries({ queryKey: tagsKeys.all });
+ },
+ });
+}
+
+/** Symmetric to {@link useAttachTagByUuid}. */
+export function useDetachTagByUuid() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (vars: { fileUuid: string; tagId: string }) =>
+ api.detachTagByUuid(vars.fileUuid, vars.tagId).match(
+ (v) => v,
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ (err) => { throw err; },
+ ),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: filesKeys.all });
+ void qc.invalidateQueries({ queryKey: tagsKeys.all });
+ },
+ });
+}
diff --git a/apps/desktop/src/router.tsx b/apps/desktop/src/router.tsx
index 7ab4370..88f555d 100644
--- a/apps/desktop/src/router.tsx
+++ b/apps/desktop/src/router.tsx
@@ -19,6 +19,7 @@ import {
} from "@tanstack/react-router";
import App from "./App";
import IndexRoute from "./routes/index";
+import DedupRoute from "./routes/dedup";
const rootRoute = createRootRoute({
component: () =>
,
@@ -30,7 +31,16 @@ const indexRoute = createRoute({
component: IndexRoute,
});
-const routeTree = rootRoute.addChildren([indexRoute]);
+// WHY placeholder: the /dedup route body lands in Task 13. The stub here
+// registers the path so `
` in CollisionPill type-checks
+// (TanStack Router validates `to` props against the registered route tree).
+const dedupRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/dedup",
+ component: DedupRoute,
+});
+
+const routeTree = rootRoute.addChildren([indexRoute, dedupRoute]);
export const router = createRouter({
routeTree,
diff --git a/apps/desktop/src/routes/dedup.tsx b/apps/desktop/src/routes/dedup.tsx
new file mode 100644
index 0000000..577ce03
--- /dev/null
+++ b/apps/desktop/src/routes/dedup.tsx
@@ -0,0 +1,313 @@
+/**
+ * `/dedup` route — candidate-duplicate management UI per spec §4.6.2.
+ *
+ * Surfaces:
+ * - Virtualised list of candidate groups (`@tanstack/react-virtual`).
+ * - Per-group "Verify this group" button → `compute_full_hash_batch`
+ * for that group's `file_uuid`s only.
+ * - Global "Verify all" button → batch over the union of every group's
+ * `file_uuid`s (footgun-aware: explicit opt-in for 10TB libraries).
+ * - Live progress display from the Zustand `verifyBatch` slice
+ * ("X of Y done — last:
"). The slice is populated by
+ * `useDomainEvents` from `AppEvent::VerifyProgress` events.
+ * - "Cancel verify batch" button — only visible when a batch is active.
+ *
+ * State sources:
+ * - server: `useCollisions` (TanStack Query, invalidated on
+ * `IndexInvalidated::CollisionsChanged` + `VerifyComplete`).
+ * - UI: `useUiStore.verifyBatch` (push value from VerifyProgress).
+ * - mutation: `useVerifyBatch` / `useCancelVerifyBatch` (TanStack
+ * Query mutations; errors flow into the notification toast).
+ *
+ * WHY virtualised list: collision groups can scale into the thousands on
+ * large media libraries (many small recurring filenames). Rendering them
+ * all up-front would blow the React commit budget. `react-virtual` only
+ * mounts visible rows, with `measureElement` for variable-height groups
+ * (a 2-file group is shorter than a 6-file group).
+ *
+ * WHY no manual `useMemo` on derivations: React Compiler 1.0 (L2) handles
+ * referentially-stable inputs automatically per Batch H standing
+ * constraints.
+ */
+import { useRef } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useUiStore } from "../stores/ui";
+import {
+ useCollisions,
+ useVerifyBatch,
+ useCancelVerifyBatch,
+} from "../queries/dedup";
+import type { CollisionGroup, FileLocationRecord } from "../bindings";
+
+/** Human-readable byte size (e.g. `"1.5 MB"`). Mirrors FileTable.humanSize. */
+function humanSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024)
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
+
+/**
+ * Extract a leaf filename from a forward-slash relative path.
+ *
+ * WHY no Node `path.basename`: Tauri WebView is browser-side; `path` is
+ * not available. The backend already enforces forward-slash separators
+ * (`MediaPath`), so a single `lastIndexOf("/")` is sufficient.
+ */
+function basename(p: string): string {
+ const i = p.lastIndexOf("/");
+ return i === -1 ? p : p.slice(i + 1);
+}
+
+interface GroupRowProps {
+ group: CollisionGroup;
+ onVerify: () => void;
+ isVerifying: boolean;
+}
+
+function GroupRow({ group, onVerify, isVerifying }: GroupRowProps) {
+ const fileCount = group.files.length;
+ // WHY representative size from first file: candidate groups share the
+ // same `quick_hash`, which by spec §4.1.1 derives from `(size, head,
+ // tail)` — every member has the same byte size. Showing the first is
+ // accurate without iterating.
+ const sizeLabel = fileCount > 0 ? humanSize(group.files[0]!.size) : "0 B";
+ const stateLabel =
+ group.verified_state === "VerifiedDuplicate"
+ ? " ✓ duplicate"
+ : group.verified_state === "VerifiedDistinct"
+ ? " ✓ distinct"
+ : group.verified_state === "Mixed"
+ ? " ⚠ mixed"
+ : "";
+
+ return (
+
+
+
+ Candidate group — {fileCount} file{fileCount === 1 ? "" : "s"}, {sizeLabel} each
+
+ {group.quick_hash.slice(0, 12)}…{stateLabel}
+
+
+
+ {isVerifying ? "Verifying…" : "Verify this group"}
+
+
+
+ {group.files.map((f: FileLocationRecord) => (
+
+ {f.volume_id.slice(0, 6)}/
+ {basename(f.relative_path)}
+ ({f.relative_path})
+
+ ))}
+
+
+ );
+}
+
+export default function DedupRoute() {
+ const { data: groups = [], isLoading, error } = useCollisions();
+ const verifyBatch = useUiStore((s) => s.verifyBatch);
+ const notifyError = useUiStore((s) => s.notifyError);
+
+ const verifyMutation = useVerifyBatch();
+ const cancelMutation = useCancelVerifyBatch();
+
+ const parentRef = useRef(null);
+ // WHY estimateSize=160: typical group with 3 files renders ~160px tall
+ // (header + 3 paths + margins). `measureElement` corrects after first
+ // mount so the estimate only matters until the row is observed.
+ // WHY eslint-disable react-hooks/incompatible-library: React Compiler
+ // 1.0 cannot safely memoize `useVirtualizer`'s return (returns functions
+ // that close over scroll-position state and would go stale if memoized).
+ // The compiler explicitly skips this hook; we acknowledge the warning
+ // here rather than re-tooling the route to side-step virtualisation.
+ // eslint-disable-next-line react-hooks/incompatible-library
+ const virtualizer = useVirtualizer({
+ count: groups.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 160,
+ overscan: 4,
+ });
+
+ const handleVerifyGroup = (group: CollisionGroup) => {
+ const fileUuids = group.files.map((f) => f.file_uuid);
+ verifyMutation.mutate(
+ { fileUuids },
+ {
+ onError: (err) => {
+ notifyError(err);
+ },
+ },
+ );
+ };
+
+ const handleVerifyAll = () => {
+ // WHY union (not deduped): Set semantics aren't required — a single
+ // file_uuid only appears in one collision group (groups partition by
+ // quick_hash, and a file has exactly one quick_hash). Flat-mapping is
+ // sufficient and avoids importing a Set just to spread it back.
+ const fileUuids = groups.flatMap((g) => g.files.map((f) => f.file_uuid));
+ if (fileUuids.length === 0) return;
+ verifyMutation.mutate(
+ { fileUuids },
+ {
+ onError: (err) => {
+ notifyError(err);
+ },
+ },
+ );
+ };
+
+ const handleCancel = () => {
+ if (verifyBatch === null) return;
+ cancelMutation.mutate(
+ { batchId: verifyBatch.batchId },
+ {
+ onError: (err) => {
+ // WHY swallow NotFound: batch already finished; nothing to do.
+ if (err.kind === "NotFound") return;
+ notifyError(err);
+ },
+ },
+ );
+ };
+
+ // ── Render branches ────────────────────────────────────────────────────
+ if (isLoading) {
+ return (
+
+
Loading candidate groups…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load candidate groups: [{error.kind}]
+
+ );
+ }
+
+ if (groups.length === 0) {
+ return (
+
+
No candidate duplicates.
+
+ );
+ }
+
+ // WHY pre-flatten progress label: keeps JSX terse + avoids inline
+ // truthy chaining when verifyBatch is null. Computed once per render.
+ const progressLabel = verifyBatch
+ ? `${verifyBatch.filesDone} of ${verifyBatch.filesTotal} done${
+ verifyBatch.latestOutcome
+ ? ` — last: ${
+ verifyBatch.latestOutcome.outcome === "Computed"
+ ? verifyBatch.latestOutcome.data.file_uuid.slice(0, 8)
+ : `error on ${verifyBatch.latestOutcome.data.file_uuid.slice(0, 8)}`
+ }`
+ : ""
+ }`
+ : null;
+
+ const items = virtualizer.getVirtualItems();
+ const totalSize = virtualizer.getTotalSize();
+
+ return (
+
+
+
+
+ {items.map((virtualRow) => {
+ const group = groups[virtualRow.index];
+ if (!group) return null;
+ return (
+
+
+ { handleVerifyGroup(group); }}
+ isVerifying={verifyMutation.isPending || verifyBatch !== null}
+ />
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/routes/index.tsx b/apps/desktop/src/routes/index.tsx
index d0c31af..34babf5 100644
--- a/apps/desktop/src/routes/index.tsx
+++ b/apps/desktop/src/routes/index.tsx
@@ -16,11 +16,20 @@ import { useTags } from "../queries/tags";
import { useSearch } from "../queries/search";
import FileGrid from "../components/FileGrid";
import FileTable from "../components/FileTable";
+import FileSidebar from "../components/FileSidebar";
import TagSidebar from "../components/TagSidebar";
import { composeVisible, computeFacets, sortByRank } from "../lib/search";
export default function IndexRoute() {
- const { data: files = [], isLoading: filesLoading } = useFiles(100);
+ // WHY 1000 (was 100): the v0.6.x display path uses an intersection
+ // pattern — visible files = useFiles(N) ∩ search hits. With N=100 a
+ // library larger than 100 files showed only the search hits that
+ // happened to be in the first-100 page (e.g. searching "mp4" in a
+ // 340-file library returned 3 of 339 actual matches). Bumping to
+ // 1000 covers the common case (<1k files) without rearchitecting.
+ // Real fix for 10k+ libraries: virtualisation + result-driven
+ // pagination; tracked separately.
+ const { data: files = [], isLoading: filesLoading } = useFiles(1000);
const { data: tags = [] } = useTags();
const viewMode = useUiStore((s) => s.viewMode);
const selectedTagId = useUiStore((s) => s.selectedTagId); // WHY kept: still used by composeVisible below
@@ -28,20 +37,46 @@ export default function IndexRoute() {
// only the post-300ms-debounce sanitised query drives `useSearch`. The
// raw `searchQuery` field exists for the input value binding only.
const debouncedQuery = useUiStore((s) => s.debouncedQuery);
+ const selectedFileUuid = useUiStore((s) => s.selectedFileUuid);
+ const setSelectedFileUuid = useUiStore((s) => s.setSelectedFileUuid);
const { data: searchHits } = useSearch(debouncedQuery);
// WHY undefined-check: useSearch returns `data: SearchHit[] | undefined` —
// undefined when query.length < MIN_QUERY_LEN per the `enabled` clause.
// Undefined means "no search active" → searchActive = false.
const searchActive = searchHits !== undefined;
- const hashSet = searchActive ? new Set(searchHits.map((h) => h.blake3_hash)) : null;
+ // WHY filter+typeguard (Task 11, spec §4.8): `SearchHit.blake3_hash` is now
+ // `string | null` because pending files (no `full_hash` yet) can still hit
+ // the FTS index. The hash-keyed compose/sort helpers operate on `Set`
+ // / `Map`, so we drop nullable hashes before constructing
+ // them. Pending files surface in the underlying `files` list and pass
+ // through `composeVisible` only when no search filter is active.
+ const hashSet = searchActive
+ ? new Set(
+ searchHits
+ .map((h) => h.blake3_hash)
+ .filter((h): h is string => h !== null),
+ )
+ : null;
const rankMap = searchActive
- ? new Map(searchHits.map((h) => [h.blake3_hash, h.rank]))
+ ? new Map(
+ searchHits
+ .filter((h): h is typeof h & { blake3_hash: string } => h.blake3_hash !== null)
+ .map((h) => [h.blake3_hash, h.rank]),
+ )
: new Map();
const baseVisible = composeVisible(files, selectedTagId, hashSet);
const visibleFiles = searchActive ? sortByRank(baseVisible, rankMap) : baseVisible;
const facetCounts = computeFacets(visibleFiles);
+ // Derive the selected file object from the UUID so FileSidebar gets a
+ // fully-typed FileWithTagsPayload without a second IPC round-trip.
+ // WHY find over a separate query: visibleFiles is already in memory;
+ // avoid a second IPC call for a single-row lookup at this scale.
+ const selectedFile = selectedFileUuid !== null
+ ? visibleFiles.find((f) => f.file_uuid === selectedFileUuid) ?? null
+ : null;
+
return (
{tags.length > 0 && (
@@ -57,6 +92,12 @@ export default function IndexRoute() {
?
: }
+ {selectedFile !== null && (
+ { setSelectedFileUuid(null); }}
+ />
+ )}
);
}
diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts
index 4fb6219..d02bf22 100644
--- a/apps/desktop/src/stores/ui.ts
+++ b/apps/desktop/src/stores/ui.ts
@@ -1,13 +1,14 @@
/**
* Global UI state — Zustand 5, slice pattern.
*
- * 4 slices:
+ * 5 slices:
* - ViewSlice — viewMode, selectedTagId
* - SearchSlice — searchQuery (raw input), debouncedQuery (drives useSearch)
* - ScanSlice — status, lastReport (StatusBar reads these)
* - NotificationsSlice — id-keyed toast queue
+ * - SelectionSlice — selectedFileUuid (drives FileSidebar)
*
- * WHY single store (not 4 separate stores): components read across
+ * WHY single store (not 5 separate stores): components read across
* concerns (e.g. StatusBar reads scan + notifications). One store +
* `useShallow` for multi-property reads keeps the API uniform.
*
@@ -18,7 +19,7 @@
*/
import { create, type StateCreator } from "zustand";
import { coreErrorMessage } from "../lib/coreError";
-import type { CoreError, ScanReport } from "../bindings";
+import type { BatchId, CoreError, FileUuid, FullHashOutcome, ScanReport } from "../bindings";
type ViewMode = "table" | "grid";
@@ -56,7 +57,42 @@ interface NotificationsSlice {
dismiss: (id: string) => void;
}
-export type UiStore = ViewSlice & SearchSlice & ScanSlice & NotificationsSlice;
+/**
+ * Per-batch full-hash compute progress, pushed via `AppEvent::VerifyProgress`.
+ *
+ * WHY nullable top-level: when no batch is running the field is `null` so
+ * consumers can quickly gate on "batch active" without checking inner fields.
+ * Task 13 (`/dedup` route) subscribes to this slice to drive a progress bar.
+ */
+interface VerifyBatchSlice {
+ verifyBatch: {
+ batchId: BatchId;
+ filesDone: number;
+ filesTotal: number;
+ latestOutcome: FullHashOutcome | null;
+ } | null;
+ setVerifyBatchProgress: (
+ batchId: BatchId,
+ filesDone: number,
+ filesTotal: number,
+ latestOutcome: FullHashOutcome,
+ ) => void;
+ clearVerifyBatch: () => void;
+}
+
+/**
+ * File selection state — drives the `FileSidebar` panel.
+ *
+ * WHY nullable (not string | undefined): null means "no file selected"
+ * and is the Zustand-idiomatic sentinel for optional singular selection.
+ * Toggle-to-deselect: clicking the same row sets it back to null.
+ */
+interface SelectionSlice {
+ selectedFileUuid: FileUuid | null;
+ setSelectedFileUuid: (uuid: FileUuid | null) => void;
+}
+
+export type UiStore = ViewSlice & SearchSlice & ScanSlice & NotificationsSlice & VerifyBatchSlice & SelectionSlice;
const createViewSlice: StateCreator = (set) => ({
viewMode: "table",
@@ -105,9 +141,26 @@ const createNotificationsSlice: StateCreator = (set) => ({
+ verifyBatch: null,
+ setVerifyBatchProgress: (batchId, filesDone, filesTotal, latestOutcome) => {
+ set({ verifyBatch: { batchId, filesDone, filesTotal, latestOutcome } });
+ },
+ clearVerifyBatch: () => {
+ set({ verifyBatch: null });
+ },
+});
+
+const createSelectionSlice: StateCreator = (set) => ({
+ selectedFileUuid: null,
+ setSelectedFileUuid: (uuid) => { set({ selectedFileUuid: uuid }); },
+});
+
export const useUiStore = create()((...a) => ({
...createViewSlice(...a),
...createSearchSlice(...a),
...createScanSlice(...a),
...createNotificationsSlice(...a),
+ ...createVerifyBatchSlice(...a),
+ ...createSelectionSlice(...a),
}));
diff --git a/crates/app/src/backfill.rs b/crates/app/src/backfill.rs
new file mode 100644
index 0000000..02c843c
--- /dev/null
+++ b/crates/app/src/backfill.rs
@@ -0,0 +1,357 @@
+//! Online backfill of `files.quick_hash` for files inserted before V011.
+//!
+//! Spec §4.1.5. Runs at 50 files/sec for installs >10k null-quick-hash
+//! files; unlimited for fresh installs (<10k null rows). Override via
+//! `PERIMA_BACKFILL_RATE` env var (files-per-second, integer; 0 = unlimited).
+//!
+//! # Design
+//!
+//! [`QuickHashBackfillWorker::spawn`] accepts a pre-built iterator of
+//! [`BackfillRow`] descriptors. The caller (CLI or desktop setup) is
+//! responsible for the `SELECT … WHERE quick_hash IS NULL` query —
+//! keeping the worker free of repository-trait dependencies that are not
+//! strictly necessary. In practice the shell calls
+//! [`perima_core::FileRepository::list_files_needing_backfill`] and
+//! passes the result as an iterator.
+//!
+//! # Rate limiting
+//!
+//! `BackfillRate::PerSec(n)` enforces an inter-file delay of
+//! `1000 / n` milliseconds using a `tokio::time::interval`. The timer
+//! fires once per file, not once per batch. [`BackfillRate::Unlimited`]
+//! skips the timer entirely (no `tokio::time::sleep` overhead).
+//!
+//! # Event emission
+//!
+//! Every 100 processed rows the worker emits
+//! `AppEvent::IndexInvalidated { reason: InvalidationReason::FilesChanged }`
+//! so the frontend can refresh its file grid without polling.
+//! Emission failures are logged at WARN and do not abort the worker.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use perima_core::{
+ AppEvent, BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, FileSize,
+ HashService, HashedFile, InvalidationReason, MediaPath, UpsertOutcome,
+};
+use tokio_util::sync::CancellationToken;
+use tracing::{info, warn};
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+/// One row from the backfill SELECT — enough to compute and store `quick_hash`.
+///
+/// The caller constructs these (typically from
+/// [`perima_core::FileRepository::list_files_needing_backfill`]) and passes
+/// them as an iterator to [`QuickHashBackfillWorker::spawn`].
+#[derive(Debug, Clone)]
+pub struct BackfillRow {
+ /// BLAKE3 full-content hash — the `files` table PK for the write path.
+ pub hash: BlakeHash,
+ /// File size in bytes — drives prefix-‖-suffix vs whole-file strategy.
+ pub size_bytes: u64,
+ /// Absolute on-disk path for the `quick_hash_prefix_suffix` read.
+ pub path: PathBuf,
+}
+
+/// Rate-limit policy for the backfill worker.
+///
+/// Callers select the policy based on the total NULL-row count (spec §4.1.5):
+/// more than 10k rows → `BackfillRate::PerSec(50)`; otherwise →
+/// [`BackfillRate::Unlimited`]. `PERIMA_BACKFILL_RATE` env var always overrides.
+#[derive(Debug, Clone, Copy)]
+pub enum BackfillRate {
+ /// Process files as fast as possible (no sleep between files).
+ Unlimited,
+ /// Process at most `n` files per second. `0` is treated as [`BackfillRate::Unlimited`].
+ PerSec(u32),
+}
+
+impl BackfillRate {
+ /// Parse the `PERIMA_BACKFILL_RATE` env var if set; return `None` if unset.
+ ///
+ /// `"0"` → `Unlimited`. Any integer → `PerSec(n)`.
+ /// Parse failure is logged at WARN; returns `None` (caller uses default).
+ pub fn from_env() -> Option {
+ let raw = std::env::var("PERIMA_BACKFILL_RATE").ok()?;
+ match raw.trim().parse::() {
+ Ok(0) => Some(Self::Unlimited),
+ Ok(n) => Some(Self::PerSec(n)),
+ Err(e) => {
+ warn!(
+ raw_value = %raw,
+ error = %e,
+ "PERIMA_BACKFILL_RATE is not a valid u32; using default rate"
+ );
+ None
+ }
+ }
+ }
+}
+
+/// Summary report returned by the `JoinHandle` when the worker exits.
+#[derive(Debug, Default)]
+pub struct BackfillReport {
+ /// Files whose `quick_hash` was successfully written.
+ pub processed: u64,
+ /// Files skipped because the hasher returned an I/O error.
+ pub skipped_io_error: u64,
+ /// Files skipped because no active `file_locations` path was available.
+ ///
+ /// These rows had `active_path = None` — the volume is unmounted or all
+ /// locations have been soft-deleted. The row's `quick_hash` remains NULL
+ /// and will be retried on the next startup.
+ pub skipped_no_active_location: u64,
+}
+
+/// Background worker that populates `files.quick_hash` for pre-V011 rows.
+///
+/// Construction: call [`QuickHashBackfillWorker::spawn`]; the struct itself
+/// is zero-sized — all state lives inside the spawned task.
+#[derive(Debug)]
+pub struct QuickHashBackfillWorker;
+
+/// How many rows to process before emitting one `IndexInvalidated` event.
+const EMIT_EVERY: u64 = 100;
+
+impl QuickHashBackfillWorker {
+ /// Spawn the backfill task on the current tokio runtime.
+ ///
+ /// The returned `JoinHandle` resolves when:
+ /// - the iterator is drained (normal exit), or
+ /// - `cancel` is triggered (early exit — returns whatever was processed).
+ ///
+ /// The task is `detached` by design — callers can optionally `.await` the
+ /// handle for the final report but are not required to. Dropping the
+ /// handle does NOT cancel the task.
+ ///
+ /// # Panics
+ ///
+ /// Must be called from within a tokio runtime context.
+ pub fn spawn(
+ iter: Box + Send>,
+ hasher: Arc,
+ repo: Arc,
+ device_id: DeviceId,
+ rate: BackfillRate,
+ bus: Arc,
+ cancel: CancellationToken,
+ ) -> tokio::task::JoinHandle {
+ tokio::spawn(run_backfill(
+ iter, hasher, repo, device_id, rate, bus, cancel,
+ ))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Internal task
+// ---------------------------------------------------------------------------
+
+/// Inner async fn so we can use `?` cleanly. Returned by `spawn`.
+async fn run_backfill(
+ iter: Box + Send>,
+ hasher: Arc,
+ repo: Arc,
+ device_id: DeviceId,
+ rate: BackfillRate,
+ bus: Arc,
+ cancel: CancellationToken,
+) -> BackfillReport {
+ let mut report = BackfillReport::default();
+
+ // Build the optional interval timer.
+ let mut interval = match rate {
+ BackfillRate::Unlimited | BackfillRate::PerSec(0) => None,
+ BackfillRate::PerSec(n) => {
+ // WHY MissedTickBehavior::Skip: if processing one file takes longer
+ // than 1/n seconds (e.g. slow disk), we do NOT want bursting to
+ // catch up — just continue at the configured rate from "now".
+ let period = Duration::from_millis(1000 / u64::from(n));
+ let mut iv = tokio::time::interval(period);
+ iv.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ Some(iv)
+ }
+ };
+
+ for row in iter {
+ // Check cancellation before each file so the task exits promptly.
+ if cancel.is_cancelled() {
+ break;
+ }
+
+ // Rate limiting: wait for the next tick before processing.
+ if let Some(iv) = interval.as_mut() {
+ // WHY select! + cancel check: tokio::time::interval::tick() is a
+ // plain `Future` that doesn't check the token; wrapping in select!
+ // lets us exit cleanly during the inter-file delay.
+ tokio::select! {
+ biased;
+ () = cancel.cancelled() => break,
+ _ = iv.tick() => {}
+ }
+ }
+
+ match process_row(&row, &*hasher, &*repo, device_id) {
+ Ok(()) => {
+ report.processed += 1;
+ }
+ Err(BackfillSkip::IoError(e)) => {
+ warn!(
+ path = %row.path.display(),
+ error = %e,
+ "backfill: skipping file — I/O error computing quick_hash"
+ );
+ report.skipped_io_error += 1;
+ }
+ }
+
+ // Emit `IndexInvalidated::FilesChanged` every EMIT_EVERY processed rows.
+ if report.processed > 0 && report.processed % EMIT_EVERY == 0 {
+ emit_invalidated(&*bus, report.processed);
+ }
+ }
+
+ // Final emission if the last batch didn't land on the boundary.
+ if report.processed % EMIT_EVERY != 0 && report.processed > 0 {
+ emit_invalidated(&*bus, report.processed);
+ }
+
+ info!(
+ processed = report.processed,
+ skipped_io = report.skipped_io_error,
+ skipped_no_loc = report.skipped_no_active_location,
+ "quick_hash backfill complete"
+ );
+
+ report
+}
+
+/// Reasons a row might be skipped without being an error in the worker itself.
+enum BackfillSkip {
+ /// The hasher could not read the file (deleted, permission denied, etc.).
+ IoError(CoreError),
+}
+
+/// Hash one file and write `quick_hash` via the repository.
+///
+/// Uses [`FileRepository::upsert_file_with_quick_hash`] which runs a
+/// `COALESCE`-guarded UPDATE — safe to call even if another writer
+/// concurrently set the value first.
+fn process_row(
+ row: &BackfillRow,
+ hasher: &dyn HashService,
+ repo: &dyn FileRepository,
+ device_id: DeviceId,
+) -> Result<(), BackfillSkip> {
+ // Compute quick_hash by reading prefix ‖ suffix of the file.
+ let quick_hash = hasher
+ .quick_hash_prefix_suffix(&row.path, row.size_bytes)
+ .map_err(BackfillSkip::IoError)?;
+
+ // Build the minimal HashedFile the repo's upsert path expects.
+ // WHY we use a dummy relative_path here: `upsert_file_with_quick_hash`
+ // only uses `file.hash` and `file.discovered.size` for the
+ // `WHERE blake3_hash = ?` + size-change detection logic; `relative_path`
+ // and `absolute_path` are not persisted by the `files` UPDATE path.
+ // Using `row.path.file_name()` gives a sensible relative name without
+ // rebuilding the full volume-relative path (we don't have volume context
+ // here — the caller only gave us the absolute path).
+ let rel = row
+ .path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("unknown");
+ let hashed_file = HashedFile {
+ discovered: DiscoveredFile {
+ absolute_path: row.path.clone(),
+ relative_path: MediaPath::new(rel),
+ size: FileSize(row.size_bytes),
+ },
+ hash: row.hash,
+ };
+
+ // Write via COALESCE-safe path — idempotent if already populated.
+ let _outcome: UpsertOutcome = repo
+ .upsert_file_with_quick_hash(&hashed_file, device_id, Some(quick_hash))
+ .map_err(BackfillSkip::IoError)?;
+
+ Ok(())
+}
+
+/// Emit `AppEvent::IndexInvalidated { reason: FilesChanged }`.
+///
+/// Failures are logged at WARN; the worker continues regardless.
+fn emit_invalidated(bus: &dyn EventBus, processed: u64) {
+ let event = AppEvent::IndexInvalidated {
+ reason: InvalidationReason::FilesChanged,
+ };
+ if let Err(e) = bus.emit(&event) {
+ warn!(
+ processed,
+ error = %e,
+ "backfill: IndexInvalidated emit failed"
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public helpers for shell wire-up
+// ---------------------------------------------------------------------------
+
+/// Build the rate policy for CLI / desktop startup.
+///
+/// Decision (spec §4.1.5):
+/// - `PERIMA_BACKFILL_RATE` env var → always wins.
+/// - `null_count > 10_000` → `PerSec(50)` (throttled for large libraries).
+/// - Otherwise → `Unlimited` (fresh installs, small libraries finish instantly).
+#[must_use]
+pub fn choose_backfill_rate(null_count: u64) -> BackfillRate {
+ // Env override wins regardless of null_count.
+ if let Some(rate) = BackfillRate::from_env() {
+ return rate;
+ }
+ if null_count > 10_000 {
+ BackfillRate::PerSec(50)
+ } else {
+ BackfillRate::Unlimited
+ }
+}
+
+/// Maximum rows to fetch per `list_files_needing_backfill` call.
+///
+/// WHY 50 000: covers any reasonable library in one SELECT. Larger libraries
+/// are still bounded by the rate limiter (50/s × 50k rows = 1 000 s max
+/// before re-query). Keeps the heap footprint of the iterator manageable
+/// (each row is ~60 bytes → ~3 MB for 50k rows).
+pub const BACKFILL_QUERY_LIMIT: u32 = 50_000;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn choose_rate_unlimited_for_small_count() {
+ // No env override; small count → Unlimited.
+ // WHY std::env is not mutated: just verify the small-count branch.
+ // (Env-var branch covered by choose_backfill_rate logic above.)
+ let rate = choose_backfill_rate(100);
+ assert!(matches!(rate, BackfillRate::Unlimited));
+ }
+
+ #[test]
+ fn choose_rate_throttled_for_large_count() {
+ let rate = choose_backfill_rate(100_001);
+ assert!(matches!(rate, BackfillRate::PerSec(50)));
+ }
+
+ #[test]
+ fn choose_rate_boundary_exactly_10000() {
+ // ≤ 10 000 → Unlimited.
+ let rate = choose_backfill_rate(10_000);
+ assert!(matches!(rate, BackfillRate::Unlimited));
+ }
+}
diff --git a/crates/app/src/bus.rs b/crates/app/src/bus.rs
index bcfb65f..9776ddc 100644
--- a/crates/app/src/bus.rs
+++ b/crates/app/src/bus.rs
@@ -116,5 +116,7 @@ const fn event_kind(e: &AppEvent) -> &'static str {
AppEvent::File(_) => "File",
AppEvent::ScanCompleted { .. } => "ScanCompleted",
AppEvent::IndexInvalidated { .. } => "IndexInvalidated",
+ AppEvent::VerifyProgress { .. } => "VerifyProgress",
+ AppEvent::VerifyComplete { .. } => "VerifyComplete",
}
}
diff --git a/crates/app/src/config.rs b/crates/app/src/config.rs
new file mode 100644
index 0000000..ae05f3d
--- /dev/null
+++ b/crates/app/src/config.rs
@@ -0,0 +1,53 @@
+//! Cross-shell data-directory resolution.
+//!
+//! 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.
+
+use std::path::PathBuf;
+
+use directories::BaseDirs;
+use perima_core::CoreError;
+
+/// The Tauri bundle identifier — single source of truth for the data-dir
+/// segment that both shells share.
+pub const BUNDLE_ID: &str = "dev.perima.desktop";
+
+/// Resolve perima's data dir for the current OS, matching Tauri's
+/// bundle-id-based `app.path().app_data_dir()` resolution exactly so
+/// CLI + desktop write to the same database.
+///
+/// - Linux: `~/.local/share/dev.perima.desktop/perima/`
+/// - macOS: `~/Library/Application Support/dev.perima.desktop/perima/`
+/// - Windows: `%APPDATA%\dev.perima.desktop\perima\`
+///
+/// # Errors
+///
+/// Returns [`CoreError::Internal`] if `directories::BaseDirs::new()` cannot
+/// resolve the per-OS data root (very rare — would mean a broken HOME).
+pub fn resolve_data_dir() -> Result {
+ let base =
+ BaseDirs::new().ok_or_else(|| CoreError::Internal("could not resolve base dirs".into()))?;
+ Ok(base.data_dir().join(BUNDLE_ID).join("perima"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn resolve_data_dir_contains_bundle_id() {
+ let path = resolve_data_dir().expect("resolve");
+ let s = path.to_string_lossy();
+ assert!(
+ s.contains(BUNDLE_ID),
+ "path {s} missing BUNDLE_ID {BUNDLE_ID}"
+ );
+ }
+
+ #[test]
+ fn resolve_data_dir_ends_with_perima() {
+ let path = resolve_data_dir().expect("resolve");
+ let last = path.file_name().unwrap_or_default().to_string_lossy();
+ assert_eq!(last, "perima", "path should end in /perima");
+ }
+}
diff --git a/crates/app/src/container.rs b/crates/app/src/container.rs
index 7eba37b..edd926c 100644
--- a/crates/app/src/container.rs
+++ b/crates/app/src/container.rs
@@ -22,14 +22,14 @@
use std::sync::Arc;
use perima_core::{
- FileRepository, HashService, MetadataRepository, Scanner, SearchRepository, TagRepository,
- VolumeRepository, events::EventBus,
+ FileRepository, HashService, IdentityCacheRepository, MetadataRepository, Scanner,
+ SearchRepository, TagRepository, VolumeRepository, events::EventBus,
};
use perima_media::ThumbnailGenerator;
use crate::{
- Bus, MetadataUseCase, ScanUseCase, SearchUseCase, TagUseCase, VolumeUseCase,
- events::EventHandler,
+ Bus, ComputeFullHashUseCase, DedupUseCase, MetadataUseCase, ScanUseCase, SearchUseCase,
+ TagUseCase, VolumeUseCase, events::EventHandler,
};
// ---------------------------------------------------------------------------
@@ -58,6 +58,12 @@ pub struct AppDeps {
pub metadata: Arc,
/// Search repository port (FTS5-backed in the live adapter).
pub search: Arc,
+ /// Tier-0 identity-cache repository port (device-local; stores
+ /// `quick_hash` and optional `full_hash` keyed on the tuple
+ /// `(device, volume, fs_file_id, size, mtime_ns)`). Wired into
+ /// `ScanUseCase` so re-scans skip rehashing unchanged files
+ /// (spec §4.3 — the v0.6.x perf landing).
+ pub identity_cache: Arc,
/// Content-hash service port.
pub hasher: Arc,
/// Filesystem walker port.
@@ -99,6 +105,10 @@ pub struct AppContainer {
pub volume: Arc,
/// [`MetadataUseCase`] — list files + attached metadata.
pub metadata: Arc,
+ /// [`ComputeFullHashUseCase`] — on-demand full-hash compute (single + batch).
+ pub compute_full_hash: Arc,
+ /// [`DedupUseCase`] — quick-hash collision listing + verified-distinct flips.
+ pub dedup: Arc,
/// Shared event bus — same `Arc` used inside every `UseCase`.
pub events: Arc,
/// Direct handle to the volume repository port.
@@ -151,6 +161,13 @@ pub struct AppContainer {
/// this single adapter handle. Same pattern as `volumes`, `tags`,
/// `metadata_repo` above.
pub files_repo: Arc,
+ /// Direct handle to the content-hash service port.
+ ///
+ /// WHY exposed (Task 8 backfill): the backfill worker needs the same
+ /// `Arc` that `ScanUseCase` holds internally so it
+ /// can compute `quick_hash_prefix_suffix` without re-constructing a
+ /// `Blake3Service`. The field is a refcount clone — zero allocation.
+ pub hasher: Arc,
}
impl std::fmt::Debug for AppContainer {
@@ -210,6 +227,7 @@ impl AppContainer {
Arc::clone(&deps.files),
Arc::clone(&deps.volumes),
Arc::clone(&deps.metadata),
+ Arc::clone(&deps.identity_cache),
Arc::clone(&deps.scanner),
Arc::clone(&deps.hasher),
Arc::clone(&deps.thumbnailer),
@@ -233,6 +251,15 @@ impl AppContainer {
Arc::clone(&deps.metadata),
Arc::clone(&events),
));
+ let compute_full_hash = Arc::new(ComputeFullHashUseCase::new(
+ Arc::clone(&deps.hasher),
+ Arc::clone(&deps.files),
+ Arc::clone(&events),
+ ));
+ let dedup = Arc::new(DedupUseCase::new(
+ Arc::clone(&deps.files),
+ Arc::clone(&events),
+ ));
// WHY clone: the same `Arc` lives inside
// `VolumeUseCase` (above) AND on the container field so shell
@@ -253,6 +280,10 @@ impl AppContainer {
// `FileRepository::list_file_locations`. Not exposed by any
// UseCase. Arc::clone is refcount-only.
let files_repo = Arc::clone(&deps.files);
+ // WHY same treatment for hasher: the backfill worker (Task 8)
+ // needs `quick_hash_prefix_suffix` without re-constructing
+ // a Blake3Service. Arc::clone is refcount-only.
+ let hasher = Arc::clone(&deps.hasher);
Arc::new(Self {
scan,
@@ -260,11 +291,14 @@ impl AppContainer {
tag,
volume,
metadata,
+ compute_full_hash,
+ dedup,
events,
volumes,
tags,
metadata_repo,
files_repo,
+ hasher,
})
}
}
@@ -281,8 +315,9 @@ mod tests {
use perima_core::{AppEvent, FileEvent, MediaPath, VolumeId};
use perima_db::{
- ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository,
- SqliteTagRepository, SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle,
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, SqliteWriter,
+ SqliteWriterHandle,
};
use perima_fs::WalkdirScanner;
use perima_hash::Blake3Service;
@@ -313,6 +348,7 @@ mod tests {
AppEvent::File(FileEvent::Created {
path: MediaPath::new("test-fanout.bin"),
volume: VolumeId::new(),
+ file_uuid: None,
})
}
@@ -349,7 +385,9 @@ mod tests {
reads.clone(),
));
let search: Arc =
- Arc::new(SqliteSearchRepository::new(writer.sender(), reads));
+ Arc::new(SqliteSearchRepository::new(writer.sender(), reads.clone()));
+ let identity_cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
let hasher: Arc = Arc::new(Blake3Service::new());
let scanner: Arc = Arc::new(WalkdirScanner::new());
let thumbnailer: Arc = Arc::new(ThumbnailGenerator::disabled());
@@ -362,6 +400,7 @@ mod tests {
tags,
metadata,
search,
+ identity_cache,
hasher,
scanner,
thumbnailer,
@@ -392,7 +431,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) + 5 (one per UseCase) = 6.
+ // ownership: 1 (container) + 7 (one per UseCase: scan, search, tag,
+ // volume, metadata, compute_full_hash, dedup) = 8.
let (_db_tmp, deps, _writer) = deps_harness();
// Pass a recording handler so we can observe fan-out from the
@@ -405,7 +445,7 @@ mod tests {
let events_strong = Arc::strong_count(&container.events);
assert_eq!(
- events_strong, 6,
+ events_strong, 8,
"container.events should be Arc-cloned once per UseCase plus the container field"
);
diff --git a/crates/app/src/dedup.rs b/crates/app/src/dedup.rs
new file mode 100644
index 0000000..0adf8e2
--- /dev/null
+++ b/crates/app/src/dedup.rs
@@ -0,0 +1,341 @@
+//! `ComputeFullHashUseCase` + `DedupUseCase` — on-demand full-hash compute
+//! and quick-hash collision dedup orchestration (spec §4.6, §4.7).
+//!
+//! # Why two use cases in one module
+//!
+//! Both are part of the v0.6.x dedup story: `ComputeFullHashUseCase` is the
+//! mutator (reads bytes, writes `full_hash`); `DedupUseCase` is the reader
+//! (lists candidate groups, marks false positives). Sharing a module keeps
+//! the imports + WHY-blocks co-located while leaving each `UseCase` struct
+//! independently constructable from the container.
+//!
+//! # Batch handle semantics
+//!
+//! [`ComputeFullHashUseCase::execute_batch`] returns a [`BatchHandle`]
+//! immediately and spawns a tokio task that processes files sequentially
+//! (avoids parallel disk thrash on HDDs — spec §4.5). Cancellation is via
+//! a `CancellationToken` stored in a `Mutex>` keyed on
+//! the `BatchId` returned to the caller. Per-file `AppEvent::VerifyProgress`
+//! emission is wired in Task 10 — Task 9 only emits
+//! `AppEvent::VerifyComplete` at the very end of the batch.
+//!
+//! # `DeviceKind` defaulting
+//!
+//! `Blake3Service::full_hash_dispatched` (Task 5) takes a `DeviceKind` to
+//! pick between mmap / rayon / sequential reads. We don't yet cache the
+//! per-volume device kind — Task 14 (`/dedup` route + sidebar Compute) will
+//! introduce that lookup. For now we default to `DeviceKind::Unknown` (which
+//! the dispatch matrix maps to the SSD path per spec §4.5.3).
+
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+use perima_core::{
+ AppEvent, BatchHandle, BatchId, BlakeHash, CollisionGroup, CoreError, DeviceId, DeviceKind,
+ EventBus, FileRepository, FileUuid, FullHashOutcome, FullHashUnavailableReason, HashService,
+};
+use tokio_util::sync::CancellationToken;
+
+// ---------------------------------------------------------------------------
+// ComputeFullHashUseCase
+// ---------------------------------------------------------------------------
+
+/// Orchestrator: on-demand compute + promote of `full_hash` for individual
+/// files or batches.
+///
+/// Dependencies are carried as `Arc` fields; zero generic parameters
+/// on the struct.
+pub struct ComputeFullHashUseCase {
+ hasher: Arc,
+ files: Arc,
+ events: Arc,
+ /// Per-batch cancellation tokens keyed on `BatchId`. Held for the lifetime
+ /// of the spawned task so [`Self::cancel_batch`] can find the right token.
+ ///
+ /// WHY `std::sync::Mutex` (not `tokio::sync::Mutex`): the lock is held only
+ /// across a `HashMap::insert` / `remove`, never across an `.await`. Sync
+ /// mutex is the right call for fast, sync-only critical sections per the
+ /// `clippy::await_holding_lock` lint guidance.
+ batches: Arc>>,
+}
+
+impl std::fmt::Debug for ComputeFullHashUseCase {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ComputeFullHashUseCase")
+ .finish_non_exhaustive()
+ }
+}
+
+impl ComputeFullHashUseCase {
+ /// Construct a `ComputeFullHashUseCase` with the given dependency ports.
+ ///
+ /// The container ([`crate::AppContainer::new`]) calls this once and shares
+ /// the resulting `Arc` across surfaces.
+ #[must_use]
+ pub fn new(
+ hasher: Arc,
+ files: Arc,
+ events: Arc,
+ ) -> Self {
+ Self {
+ hasher,
+ files,
+ events,
+ batches: Arc::new(Mutex::new(HashMap::new())),
+ }
+ }
+
+ /// Compute and persist the full BLAKE3 hash for a single `file_uuid`.
+ ///
+ /// Looks up the active mounted path, reads the bytes via
+ /// [`HashService::full_hash_dispatched`], promotes the hash via
+ /// [`FileRepository::update_full_hash`], and returns the freshly-computed
+ /// value.
+ ///
+ /// # Errors
+ /// - [`CoreError::FullHashUnavailable`] with reason
+ /// [`FullHashUnavailableReason::NotMounted`] when no active mounted
+ /// location exists for `file_uuid`.
+ /// - [`CoreError::FullHashUnavailable`] with reason
+ /// [`FullHashUnavailableReason::IoError`] when reading the bytes fails.
+ /// - Propagates other `CoreError` variants from the underlying ports.
+ #[tracing::instrument(
+ name = "compute_full_hash_single",
+ skip(self),
+ err(level = "warn", Display)
+ )]
+ pub async fn execute_single(&self, file_uuid: FileUuid) -> Result {
+ compute_one(&*self.hasher, &*self.files, file_uuid).await
+ }
+
+ /// Spawn a batch task that computes `full_hash` for every uuid in
+ /// `file_uuids`, returning a [`BatchHandle`] immediately.
+ ///
+ /// The batch is processed sequentially (one file at a time) to avoid
+ /// parallel disk thrash on HDDs — spec §4.5. After each file completes
+ /// (successfully or not) the worker emits [`AppEvent::VerifyProgress`]
+ /// with the per-file outcome so the frontend can drive a progress bar.
+ /// One [`AppEvent::VerifyComplete`] is emitted at the very end.
+ ///
+ /// # Errors
+ /// Currently infallible — all per-file failures are routed through
+ /// `AppEvent::VerifyProgress` rather than aborting the batch.
+ #[allow(clippy::unused_async)]
+ pub async fn execute_batch(&self, file_uuids: Vec) -> Result {
+ let batch_id = BatchId::new();
+ let files_total = u32::try_from(file_uuids.len()).unwrap_or(u32::MAX);
+
+ let cancel = CancellationToken::new();
+ // Insert into the map BEFORE spawning so a racy `cancel_batch` call
+ // is observable even before the task starts polling.
+ self.batches
+ .lock()
+ .map_err(|e| CoreError::Internal(format!("batches lock poisoned: {e}")))?
+ .insert(batch_id, cancel.clone());
+
+ let hasher = Arc::clone(&self.hasher);
+ let files = Arc::clone(&self.files);
+ let events = Arc::clone(&self.events);
+ let batches = Arc::clone(&self.batches);
+
+ tokio::spawn(async move {
+ let mut files_done: u32 = 0;
+
+ for uuid in file_uuids {
+ if cancel.is_cancelled() {
+ break;
+ }
+
+ let result = compute_one(&*hasher, &*files, uuid).await;
+
+ // Build the per-file outcome regardless of success/failure so
+ // the frontend can distinguish `Computed` from `Failed` and
+ // update its progress bar with the right icon per file.
+ let outcome = match result {
+ Ok(hash) => FullHashOutcome::Computed {
+ file_uuid: uuid,
+ hash,
+ },
+ Err(ref e) => {
+ tracing::warn!(
+ file_uuid = %uuid.0,
+ error = %e,
+ "batch full_hash compute failed",
+ );
+ FullHashOutcome::Failed {
+ file_uuid: uuid,
+ error: e.clone(),
+ }
+ }
+ };
+
+ files_done = files_done.saturating_add(1);
+
+ // WHY best-effort emit (ignore error): a slow/overflowed bus
+ // must not abort the batch. The bus logs its own warning on
+ // capacity-full. The `let _ =` silences the `must_use` lint.
+ let _ = events.emit(&AppEvent::VerifyProgress {
+ batch_id,
+ files_done,
+ files_total,
+ latest_outcome: outcome,
+ });
+ }
+
+ // Final `VerifyComplete` event so the frontend knows the batch is done.
+ if let Err(e) = events.emit(&AppEvent::VerifyComplete { batch_id }) {
+ tracing::warn!(?e, batch = %batch_id.0, "VerifyComplete emit failed");
+ }
+
+ // Clean up the cancellation token entry so the map doesn't leak
+ // across long-running processes.
+ if let Ok(mut map) = batches.lock() {
+ map.remove(&batch_id);
+ }
+ });
+
+ Ok(BatchHandle {
+ batch_id,
+ total: files_total,
+ })
+ }
+
+ /// Cancel an in-flight batch by id.
+ ///
+ /// # Errors
+ /// Returns `CoreError::NotFound` if no batch with `batch_id` is currently
+ /// running (already finished, never started, or already cancelled).
+ #[allow(clippy::unused_async)]
+ pub async fn cancel_batch(&self, batch_id: BatchId) -> Result<(), CoreError> {
+ let token = self
+ .batches
+ .lock()
+ .map_err(|e| CoreError::Internal(format!("batches lock poisoned: {e}")))?
+ .remove(&batch_id);
+ token.map_or_else(
+ || {
+ Err(CoreError::NotFound(format!(
+ "no in-flight batch with id={}",
+ batch_id.0
+ )))
+ },
+ |t| {
+ t.cancel();
+ Ok(())
+ },
+ )
+ }
+}
+
+/// Shared per-file compute path used by both `execute_single` and
+/// `execute_batch`. Fully sync-async-bridged so both entry points share
+/// the same I/O + lookup + promote sequence.
+///
+/// WHY async fn (with no `.await` today): keeps the signature uniform with
+/// the public `execute_single` / `execute_batch` `UseCase` contract so a
+/// future `spawn_blocking` rewrite (if BLAKE3 hashing on the worker thread
+/// becomes a bottleneck) is a single-site change without churning every
+/// caller. Mirrors the `clippy::unused_async` pattern used in
+/// `volume::execute` and the other `UseCase` methods.
+#[allow(clippy::unused_async)]
+async fn compute_one(
+ hasher: &dyn HashService,
+ files: &dyn FileRepository,
+ file_uuid: FileUuid,
+) -> Result {
+ let (_existing_hash, abs_path, size_bytes) =
+ files
+ .lookup_by_file_uuid(file_uuid)?
+ .ok_or_else(|| CoreError::FullHashUnavailable {
+ // WHY NotMounted when path is unavailable: the row exists (or
+ // doesn't), but the user-actionable message is "mount the
+ // volume" — the frontend distinguishes by the `kind` discriminant
+ // in `FullHashUnavailableReason`.
+ reason: FullHashUnavailableReason::NotMounted {
+ volume_id: file_uuid.0.to_string(),
+ },
+ })?;
+
+ // WHY DeviceKind::Unknown default: per-volume device kind caching is
+ // tracked in Task 14 (sidebar Compute UX). The dispatch matrix
+ // (spec §4.5.3) maps Unknown → SSD path, which is the safer default
+ // for 2026 hardware.
+ let device_kind = DeviceKind::Unknown;
+
+ // WHY synchronous hash here (no spawn_blocking): the call sites are
+ // Tauri commands running on tokio worker threads. BLAKE3 over a single
+ // file completes in milliseconds for typical media; spawn_blocking
+ // would add scheduler hops without measurable benefit. The scan-path
+ // already accepts the same trade-off via rayon's `par_iter`.
+ let new_hash = hasher
+ .full_hash_dispatched(&abs_path, size_bytes, device_kind)
+ .map_err(|e| CoreError::FullHashUnavailable {
+ reason: FullHashUnavailableReason::IoError {
+ message: e.to_string(),
+ },
+ })?;
+
+ files.update_full_hash(file_uuid, new_hash)?;
+
+ Ok(new_hash)
+}
+
+// ---------------------------------------------------------------------------
+// DedupUseCase
+// ---------------------------------------------------------------------------
+
+/// Orchestrator: dedup queries (list candidate groups) and dedup mutations
+/// (mark verified-distinct).
+///
+/// Dependencies are carried as `Arc` fields; zero generic
+/// parameters on the struct.
+pub struct DedupUseCase {
+ files: Arc,
+ events: Arc,
+}
+
+impl std::fmt::Debug for DedupUseCase {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("DedupUseCase").finish_non_exhaustive()
+ }
+}
+
+impl DedupUseCase {
+ /// Construct a `DedupUseCase` with the given dependency ports.
+ #[must_use]
+ pub const fn new(files: Arc, events: Arc) -> Self {
+ Self { files, events }
+ }
+
+ /// List every group of files whose `quick_hash` matches one or more
+ /// other rows AND that have not been marked `verified_distinct`.
+ ///
+ /// # Errors
+ /// Propagates `CoreError` from the underlying [`FileRepository`].
+ pub fn list_collisions(&self) -> Result, CoreError> {
+ // WHY events held but unused here: list is read-only — no event
+ // emission. The bus is held for symmetry with the mutating method
+ // and to enable per-call instrumentation in a future expansion.
+ let _ = &self.events;
+ self.files.list_quick_hash_collisions()
+ }
+
+ /// Mark the given file uuids as `verified_distinct = 1`.
+ ///
+ /// Memorises that these files share a `quick_hash` but were verified
+ /// to have distinct `full_hash` values — they will be excluded from
+ /// subsequent `list_collisions` calls.
+ ///
+ /// # Errors
+ /// Propagates `CoreError` from the underlying [`FileRepository`].
+ pub fn mark_verified_distinct(
+ &self,
+ file_uuids: Vec,
+ device: DeviceId,
+ ) -> Result<(), CoreError> {
+ self.files.mark_verified_distinct(file_uuids, device)?;
+ // The writer emits `IndexInvalidated::CollisionsChanged` after COMMIT,
+ // so we don't double-fire here.
+ Ok(())
+ }
+}
diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs
index 27e7298..dd262b1 100644
--- a/crates/app/src/lib.rs
+++ b/crates/app/src/lib.rs
@@ -25,8 +25,11 @@
#![forbid(unsafe_code)]
+pub mod backfill;
pub mod bus;
+pub mod config;
pub mod container;
+pub mod dedup;
pub mod events;
pub mod metadata;
pub mod scan;
@@ -35,8 +38,10 @@ pub mod tag;
pub mod telemetry;
pub mod volume;
+pub use backfill::{BackfillRate, BackfillReport, BackfillRow, QuickHashBackfillWorker};
pub use bus::Bus;
pub use container::{AppContainer, AppDeps};
+pub use dedup::{ComputeFullHashUseCase, DedupUseCase};
pub use events::EventHandler;
pub use metadata::{MetadataCommand, MetadataOutput, MetadataUseCase};
pub use scan::{
diff --git a/crates/app/src/metadata.rs b/crates/app/src/metadata.rs
index 9da9228..b39753a 100644
--- a/crates/app/src/metadata.rs
+++ b/crates/app/src/metadata.rs
@@ -98,12 +98,17 @@ pub enum MetadataOutput {
Files(Vec),
/// Response to [`MetadataCommand::ListFilesWithMetadata`] — each record
- /// paired with its optional metadata.
+ /// paired with its optional metadata and the raw `quick_hash` hex string.
///
/// `None` metadata means extraction is pending or failed; callers
/// MUST NOT treat `None` as "file has no metadata" for display
/// purposes — "pending" is the correct label.
- FilesWithMetadata(Vec<(FileLocationRecord, Option)>),
+ ///
+ /// The third element is `files.quick_hash` as a lowercase hex string, or
+ /// `None` if the backfill worker has not yet run. See
+ /// [`MetadataRepository::list_with_metadata`] for the placeholder
+ /// detection contract.
+ FilesWithMetadata(Vec<(FileLocationRecord, Option, Option)>),
}
/// Orchestrator: file-location listing and metadata join.
@@ -417,11 +422,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/scan.rs b/crates/app/src/scan.rs
index 9af980f..e632f2b 100644
--- a/crates/app/src/scan.rs
+++ b/crates/app/src/scan.rs
@@ -17,9 +17,9 @@ use std::time::{Duration, Instant};
use serde::Serialize;
use perima_core::{
- BlakeHash, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository, HashService,
- HashedFile, MediaPath, MetadataExtractor, MetadataRepository, Scanner, UpsertOutcome, VolumeId,
- VolumeRepository,
+ BlakeHash, CacheEntry, CacheKey, CoreError, DeviceId, DiscoveredFile, EventBus, FileRepository,
+ FileStat, HashService, HashedFile, IdentityCacheRepository, MediaPath, MetadataExtractor,
+ MetadataRepository, Scanner, UpsertOutcome, VolumeId, VolumeRepository,
};
use perima_media::{
CompositeExtractor, ImageExtractor, MetadataQueue, ThumbnailGenerator, VideoExtractor,
@@ -279,6 +279,11 @@ pub struct ScanUseCase {
files: Arc,
volumes: Arc,
metadata: Arc,
+ // WHY `cache` is held alongside the other ports (Task 7): on every
+ // file the scan loop consults the Tier-0 identity cache before any
+ // hash compute (spec §4.3). Cache hits skip the file read entirely
+ // — that's the v0.6.x re-scan perf landing.
+ cache: Arc,
scanner: Arc,
hasher: Arc,
thumbnailer: Arc,
@@ -297,16 +302,37 @@ impl std::fmt::Debug for ScanUseCase {
}
}
+/// Per-file resolution result from [`ScanUseCase::resolve_with_cache`].
+///
+/// Triple of `(discovered_file, blake3_hash, quick_hash)`. `quick_hash`
+/// is `Some` for cache hits (`entry.quick_hash`) and cache misses (the
+/// freshly-computed fingerprint). It is `None` only when a cache lookup
+/// error demoted the file to the miss path and hashing was also skipped
+/// (cancellation). The scan loop passes this value through to
+/// `persist_file` to eagerly populate `files.quick_hash` per spec §4.1.1.
+///
+/// WHY type alias (not a struct): the 3-tuple is internal to this module;
+/// a struct would require field names and `pub` visibility for no gain.
+/// The alias suppresses `clippy::type_complexity` at all three use sites.
+type ResolveEntry = Result<(DiscoveredFile, BlakeHash, Option), CoreError>;
+
impl ScanUseCase {
/// Construct a `ScanUseCase` with the given dependency ports.
///
- /// The container (Task 7) calls this once and shares the resulting
- /// `Arc` across surfaces.
+ /// The container (`AppContainer::new`) calls this once and shares the
+ /// resulting `Arc` across surfaces.
+ ///
+ /// WHY 8 ports (clippy `too_many_arguments` accepted): every field is
+ /// an `Arc` port the scan use case must own; collapsing into a
+ /// builder struct adds boilerplate without changing call sites
+ /// (the container is the only production caller).
#[must_use]
+ #[allow(clippy::too_many_arguments)]
pub fn new(
files: Arc,
volumes: Arc,
metadata: Arc,
+ cache: Arc,
scanner: Arc,
hasher: Arc,
thumbnailer: Arc,
@@ -316,6 +342,7 @@ impl ScanUseCase {
files,
volumes,
metadata,
+ cache,
scanner,
hasher,
thumbnailer,
@@ -454,23 +481,55 @@ impl ScanUseCase {
.take_while(|_| !cancel.is_cancelled())
.collect();
- // Parallel hash. WHY cancellation check at the top of each map
- // closure: 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.
+ // Resolve hashing: Tier-0 cache first, then quick_hash on miss.
+ //
+ // WHY two phases (sequential cache lookup, then parallel quick_hash):
+ // the cache lookup is a synchronous SELECT through the read pool;
+ // serialising the lookups keeps the read-pool contention low and
+ // batches all the misses into a single rayon par_iter that pays
+ // its overhead once. Cache hits skip every file read by design
+ // (spec §4.3) — that's the v0.6.x re-scan perf landing.
+ //
+ // WHY dry-run still uses `full_hash`: dry-run scans skip volume
+ // detection (volume_info is None), so the cache key (which needs
+ // a real volume_id) cannot be constructed. Dry-run is for
+ // standalone hash printing; a future minor can add a CLI flag
+ // wiring quick_hash there too. Out of scope for v0.6.x.
let cancel_token = cancel.clone();
- let hasher = Arc::clone(&self.hasher);
- let results: Vec> = discovered
- .into_par_iter()
- .map(|d| {
- if cancel_token.is_cancelled() {
- return Err(CoreError::Internal("cancelled".into()));
- }
- let h = hasher.full_hash(&d.absolute_path)?;
- Ok((d, h))
- })
- .collect();
+ // WHY 3-tuple (d, h, quick_hash): dry-run carries None for quick_hash
+ // (no volume_id available so the cache key cannot be built; full_hash
+ // is used instead). The cache-aware path carries Some(quick_hash) from
+ // resolve_with_cache so persist_file can eagerly populate files.quick_hash
+ // per spec §4.1.1 — see WHY block in resolve_with_cache.
+ let results: Vec = if dry_run {
+ // Legacy dry-run path: parallel full_hash, no cache. Preserves
+ // pre-Task-7 behaviour byte-for-byte.
+ let hasher = Arc::clone(&self.hasher);
+ discovered
+ .into_par_iter()
+ .map(|d| {
+ if cancel_token.is_cancelled() {
+ return Err(CoreError::Internal("cancelled".into()));
+ }
+ let h = hasher.full_hash(&d.absolute_path)?;
+ Ok((d, h, None))
+ })
+ .collect()
+ } else {
+ // Cache-aware path. `volume_info` is Some in the non-dry-run
+ // arm (built unconditionally above when `!dry_run`).
+ let vol_id = volume_info.as_ref().map_or_else(
+ || {
+ // Defensive: should be unreachable since !dry_run
+ // implies volume_info was populated. Fall back to nil
+ // so we degrade gracefully rather than panic.
+ tracing::warn!("non-dry-run scan with no resolved volume; using nil fallback",);
+ VolumeId(uuid::Uuid::nil())
+ },
+ |(v, _, _)| *v,
+ );
+ self.resolve_with_cache(discovered, &cancel_token, device_id, vol_id)
+ };
let mut report = ScanReport {
volume_label: volume_info.as_ref().map(|(_, label, _)| label.clone()),
@@ -483,7 +542,7 @@ impl ScanUseCase {
for res in results {
report.files_seen += 1;
match res {
- Ok((d, h)) => {
+ Ok((d, h, quick_hash)) => {
report.bytes_hashed += d.size.0;
report.per_file_entries.push(ScanReportEntry {
hash: h,
@@ -499,7 +558,7 @@ impl ScanUseCase {
let volume = volume_info
.as_ref()
.map_or_else(|| VolumeId(uuid::Uuid::nil()), |(v, _, _)| *v);
- match persist_file(&*self.files, &d, &h, device_id, volume) {
+ match persist_file(&*self.files, &d, &h, quick_hash, device_id, volume) {
Ok(outcome) => {
// WHY: sentinel migration runs per-file,
// scoped to (relative_path, sentinel
@@ -622,15 +681,181 @@ impl ScanUseCase {
Ok(report)
}
+
+ /// Resolve `(DiscoveredFile, BlakeHash)` for every entry, consulting
+ /// the Tier-0 identity cache before any hash compute (spec §4.3).
+ ///
+ /// Phase 1: stat each file + look up the cache row sequentially. Hits
+ /// produce a hash directly from the cached entry; misses are pushed
+ /// to a separate vec for parallel hashing in phase 2.
+ ///
+ /// Phase 2: rayon `par_iter` `quick_hash_prefix_suffix` on the misses.
+ /// After hashing, the cache row is inserted (best-effort — logged on
+ /// failure but not propagated, since the cache is a perf optimisation
+ /// not a correctness gate).
+ ///
+ /// WHY two phases (not single `par_iter`): the cache lookup is a
+ /// synchronous read-pool query; serialising lookups keeps the pool
+ /// wait-times bounded and means hits never pay the rayon overhead.
+ /// The misses still parallelise in phase 2 — preserves the pre-Task-7
+ /// throughput on cold caches.
+ ///
+ /// WHY cancellation checks at every yield + at the top of each rayon
+ /// closure: matches the pre-Task-7 contract — Ctrl-C stops the scan
+ /// promptly, even mid-cache-resolution.
+ ///
+ /// WHY `blake3_hash = quick_hash` placeholder for new rows: V001
+ /// declares `files.blake3_hash TEXT PRIMARY KEY NOT NULL`. Until a
+ /// future V012 relaxes this to nullable (tracked GH issue post-Task-7),
+ /// new rows ride a placeholder content-hash equal to the `quick_hash`
+ /// fingerprint. Task 9's `compute_full_hash` later promotes the real
+ /// `full_hash` (`UPDATE`s both the placeholder AND the new `full_hash`
+ /// column atomically). Cache hits with `full_hash` already populated
+ /// use the canonical `full_hash` directly — no placeholder needed.
+ /// Each success entry is `(discovered_file, blake3_hash, quick_hash)`.
+ ///
+ /// `quick_hash` is the cheap prefix+suffix fingerprint used to populate
+ /// `files.quick_hash` on INSERT (spec §4.1.1):
+ /// - Cache hit: `Some(entry.quick_hash)` — available without re-hashing.
+ /// - Cache miss: `Some(h)` — `h` is the freshly-computed `quick_hash`.
+ ///
+ /// Both branches are `Some`; `None` only arises on cache lookup failure
+ /// where the file was demoted to the miss path and hashed anyway.
+ fn resolve_with_cache(
+ &self,
+ discovered: Vec,
+ cancel: &CancellationToken,
+ device: DeviceId,
+ volume: VolumeId,
+ ) -> Vec {
+ // Phase 1: per-file stat + cache lookup. `hits` carry a (DiscoveredFile,
+ // BlakeHash, quick_hash) ready to push into results; `misses` carry
+ // the file + its CacheKey so phase 2 can insert the fresh cache row
+ // after hashing.
+ let mut results: Vec = Vec::with_capacity(discovered.len());
+ let mut misses: Vec<(DiscoveredFile, CacheKey)> = Vec::new();
+ for d in discovered {
+ if cancel.is_cancelled() {
+ results.push(Err(CoreError::Internal("cancelled".into())));
+ continue;
+ }
+ let stat = match self.scanner.stat_with_id(&d.absolute_path) {
+ Ok(s) => s,
+ Err(e) => {
+ // WHY push as Err: matches the pre-Task-7 shape — a
+ // failed hash also yielded Err in the old par_iter.
+ // The outer loop classifies these as files_errored.
+ results.push(Err(e));
+ continue;
+ }
+ };
+ let key = make_cache_key(device, volume, stat);
+ match self.cache.lookup(&key) {
+ Ok(Some(entry)) => {
+ let h = cached_hash(&entry);
+ // WHY carry entry.quick_hash separately: `h` may be
+ // `full_hash` when the cache row has been promoted,
+ // but `files.quick_hash` must always store the cheap
+ // prefix+suffix fingerprint, not the full hash.
+ results.push(Ok((d, h, Some(entry.quick_hash))));
+ }
+ Ok(None) => misses.push((d, key)),
+ Err(e) => {
+ // Lookup failure: degrade to a miss path so the file is
+ // still hashed + persisted. WHY warn (not propagate):
+ // the cache is a perf optimisation; a transient read-
+ // pool error must not abort the scan.
+ tracing::warn!(error = %e, path = %d.absolute_path.display(),
+ "Tier-0 cache lookup failed; falling back to miss path");
+ misses.push((d, key));
+ }
+ }
+ }
+
+ // Phase 2: parallel quick_hash_prefix_suffix on misses.
+ let cancel_for_par = cancel.clone();
+ let hasher = Arc::clone(&self.hasher);
+ let miss_results: Vec> = misses
+ .into_par_iter()
+ .map(|(d, key)| {
+ if cancel_for_par.is_cancelled() {
+ return Err(CoreError::Internal("cancelled".into()));
+ }
+ let h = hasher.quick_hash_prefix_suffix(&d.absolute_path, d.size.0)?;
+ Ok((d, h, key))
+ })
+ .collect();
+
+ // Phase 3: insert cache rows for the freshly-hashed misses, then
+ // accumulate into results. Cache upsert errors are logged (not
+ // propagated) — the file write path is the source of truth.
+ for r in miss_results {
+ match r {
+ Ok((d, h, key)) => {
+ let entry = CacheEntry {
+ quick_hash: h,
+ full_hash: None,
+ };
+ if let Err(e) = self.cache.upsert(&key, &entry) {
+ tracing::warn!(
+ error = %e,
+ path = %d.absolute_path.display(),
+ "Tier-0 cache upsert failed; continuing scan",
+ );
+ }
+ // WHY Some(h) for miss path: h IS the quick_hash
+ // (quick_hash_prefix_suffix result). Carry it so the
+ // persist step can populate files.quick_hash eagerly.
+ results.push(Ok((d, h, Some(h))));
+ }
+ Err(e) => results.push(Err(e)),
+ }
+ }
+ results
+ }
+}
+
+/// Build the Tier-0 cache lookup key from the per-file stat triple.
+///
+/// WHY a free function (not an inherent on `CacheKey`): `CacheKey` lives
+/// in `crates/core` and `FileStat` lives there too, but the conversion
+/// also assigns the (device, volume) coordinates which only the use case
+/// knows. Keeping the constructor here keeps `crates/core` framework-free.
+const fn make_cache_key(device: DeviceId, volume: VolumeId, stat: FileStat) -> CacheKey {
+ CacheKey {
+ device_id: device,
+ volume_id: volume,
+ fs_file_id: stat.fs_file_id,
+ size_bytes: stat.size_bytes,
+ mtime_ns: stat.mtime_ns,
+ }
+}
+
+/// Project a cached entry to the `BlakeHash` `ScanUseCase` will write.
+///
+/// Prefers `full_hash` when present (canonical content address); falls
+/// back to `quick_hash` as a placeholder when only the cheap fingerprint
+/// has been computed (the v0.6.x lazy-full-hash workflow).
+const fn cached_hash(entry: &CacheEntry) -> BlakeHash {
+ match entry.full_hash {
+ Some(h) => h,
+ None => entry.quick_hash,
+ }
}
/// Persist a single hashed file: upsert the content record, then the
/// location record. Returns the location outcome so the caller can
/// classify the result as new/existing.
+///
+/// `quick_hash` is the cheap BLAKE3 prefix+suffix fingerprint. When
+/// `Some`, the adapter populates `files.quick_hash` on INSERT per spec
+/// §4.1.1, enabling `list_quick_hash_collisions` (Task 9) to return
+/// candidates immediately for files scanned post-Task-7-fix.
fn persist_file(
repo: &dyn FileRepository,
d: &DiscoveredFile,
h: &BlakeHash,
+ quick_hash: Option,
device: DeviceId,
volume: VolumeId,
) -> Result {
@@ -638,7 +863,7 @@ fn persist_file(
discovered: d.clone(),
hash: *h,
};
- repo.upsert_file(&hf, device)?;
+ repo.upsert_file_with_quick_hash(&hf, device, quick_hash)?;
repo.upsert_location(h, volume, &d.relative_path, device)
}
@@ -673,10 +898,10 @@ mod tests {
use std::io::Write;
use std::sync::Mutex;
- use perima_core::{AppEvent, FileLocationRecord, MediaMetadata};
+ use perima_core::{AppEvent, MediaMetadata};
use perima_db::{
- ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository,
- SqliteWriter, SqliteWriterHandle,
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteVolumeRepository, SqliteWriter, SqliteWriterHandle,
};
use perima_fs::WalkdirScanner;
use perima_hash::Blake3Service;
@@ -718,7 +943,7 @@ mod tests {
&self,
_limit: usize,
_volume: Option,
- ) -> Result)>, CoreError> {
+ ) -> Result, CoreError> {
Ok(vec![])
}
fn update_thumbnail(
@@ -779,8 +1004,12 @@ mod tests {
// Use the real metadata repo for the DB so persistence tests
// see a consistent view. A second recording mock is wired via
// Arc dyn but held out-of-band for tests that want it.
- let _sqlite_meta: Arc =
- Arc::new(SqliteMetadataRepository::new(writer.sender(), reads));
+ let _sqlite_meta: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
let recording = Arc::new(RecordingMetadata::default());
let metadata: Arc = recording.clone();
@@ -793,6 +1022,7 @@ mod tests {
files,
volumes,
metadata,
+ cache,
scanner,
hasher,
thumbnailer,
diff --git a/crates/app/src/search.rs b/crates/app/src/search.rs
index 5d87fcb..6cbfcb1 100644
--- a/crates/app/src/search.rs
+++ b/crates/app/src/search.rs
@@ -210,14 +210,16 @@ mod tests {
// canonical structural fix.
#[allow(clippy::disallowed_methods)]
let conn = Connection::open(db_path).unwrap();
- // WHY explicit column list: matches V007 `search_content` schema
- // (blake3_hash, filename, relative_path, mime_type, camera_model,
- // captured_at, tags). Omitting optional columns uses their DEFAULT ''.
+ // WHY explicit column list: matches V011 `search_content` schema
+ // (file_uuid, blake3_hash, filename, relative_path, mime_type,
+ // camera_model, captured_at, tags). `file_uuid` is `NOT NULL UNIQUE`
+ // post-V011; we synthesise a fresh UUIDv7 per seed call so each row
+ // satisfies the constraint.
conn.execute(
"INSERT INTO search_content \
- (blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) \
- VALUES (?1, '', ?2, ?3, '', '', '')",
- rusqlite::params![hash, path, mime],
+ (file_uuid, blake3_hash, filename, relative_path, mime_type, camera_model, captured_at, tags) \
+ VALUES (?1, ?2, '', ?3, ?4, '', '', '')",
+ rusqlite::params![uuid::Uuid::now_v7().to_string(), hash, path, mime],
)
.unwrap();
}
@@ -246,7 +248,7 @@ mod tests {
assert_eq!(out.hits.len(), 1, "one seeded row matches 'vacation'");
assert_eq!(out.hits[0].relative_path, "photos/vacation.jpg");
- assert_eq!(out.hits[0].blake3_hash, "aabbcc");
+ assert_eq!(out.hits[0].blake3_hash.as_deref(), Some("aabbcc"));
}
#[tokio::test]
diff --git a/crates/app/src/tag.rs b/crates/app/src/tag.rs
index 1d5e02c..14222e3 100644
--- a/crates/app/src/tag.rs
+++ b/crates/app/src/tag.rs
@@ -58,6 +58,11 @@ pub struct FileWithTags {
pub metadata: Option,
/// Active tags for this content hash.
pub tags: Vec,
+ /// Quick-hash value as a lowercase hex string, or `None` if the backfill
+ /// worker has not yet run for this row. Used by the frontend to detect
+ /// placeholder rows (`hash == quick_hash`). See
+ /// [`MetadataRepository::list_with_metadata`] for the contract.
+ pub quick_hash: Option,
}
/// Filter parameters for [`TagCommand::ListFilesWithTags`].
@@ -267,17 +272,28 @@ impl TagUseCase {
let rows = self
.metadata
.list_with_metadata(f.limit as usize, f.volume)?;
- let hashes: Vec = rows.iter().map(|(loc, _)| loc.hash).collect();
+ // WHY `flat_map` + `Option::iter`: post-Task-11 `loc.hash` is
+ // `Option` because pending files (no `full_hash`
+ // yet) carry no content address. Tag attachment in v0.6.x is
+ // still keyed on `blake3_hash` (the schema pivot to
+ // `file_uuid` is a follow-up — #161); rows with `None` hash
+ // contribute zero tag-lookup keys and surface with empty
+ // 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();
let tag_map = self.tags.tags_for_hashes(&hashes)?;
let files = rows
.into_iter()
- .map(|(loc, meta)| {
- let hash = loc.hash;
- let tags = tag_map.get(&hash).cloned().unwrap_or_default();
+ .map(|(loc, meta, quick_hash)| {
+ let tags = loc.hash.map_or_else(Vec::new, |h| {
+ tag_map.get(&h).cloned().unwrap_or_default()
+ });
FileWithTags {
location: loc,
metadata: meta,
tags,
+ quick_hash,
}
})
.collect();
diff --git a/crates/app/src/telemetry.rs b/crates/app/src/telemetry.rs
index 6531a6c..18d4616 100644
--- a/crates/app/src/telemetry.rs
+++ b/crates/app/src/telemetry.rs
@@ -58,6 +58,17 @@ impl EventHandler for LogEventHandler {
AppEvent::IndexInvalidated { reason } => {
tracing::info!(?reason, "index invalidated");
}
+ AppEvent::VerifyProgress {
+ batch_id,
+ files_done,
+ files_total,
+ ..
+ } => {
+ tracing::info!(?batch_id, files_done, files_total, "verify progress");
+ }
+ AppEvent::VerifyComplete { batch_id } => {
+ tracing::info!(?batch_id, "verify complete");
+ }
}
}
}
@@ -74,6 +85,7 @@ mod tests {
let event = AppEvent::File(FileEvent::Created {
path: MediaPath::new("foo.txt"),
volume: VolumeId(Uuid::nil()),
+ file_uuid: None,
});
// `handle` returns (); success = no panic.
handler.handle(event).await;
@@ -90,6 +102,7 @@ mod tests {
from: MediaPath::new("a.txt"),
to: MediaPath::new("b.txt"),
volume: VolumeId(Uuid::nil()),
+ file_uuid: None,
});
handler.handle(event).await;
}
diff --git a/crates/app/tests/backfill_populates_quick_hash.rs b/crates/app/tests/backfill_populates_quick_hash.rs
new file mode 100644
index 0000000..1160ab0
--- /dev/null
+++ b/crates/app/tests/backfill_populates_quick_hash.rs
@@ -0,0 +1,294 @@
+//! Verify [`QuickHashBackfillWorker`] populates `files.quick_hash` for NULL rows.
+//!
+//! Spec §4.1.5. Simulates the legacy-backfill scenario: 10 file rows exist in
+//! the DB with `quick_hash IS NULL` (inserted before V011 or inserted without
+//! a fingerprint). The worker must compute + store `quick_hash` for each.
+//!
+//! WHY raw SQL inserts: the writer's eager-populate path (Task 7) would set
+//! `quick_hash` on every `UpsertFile` with a `Some` value. To simulate the
+//! pre-V011 state we bypass the writer and insert directly, then verify the
+//! worker fills the NULLs.
+
+#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs.
+
+use std::sync::Arc;
+
+use perima_app::{BackfillRate, BackfillReport, BackfillRow, QuickHashBackfillWorker};
+use perima_core::HashService as _;
+use perima_core::{
+ BlakeHash, CoreError, DeviceId, EventBus, FileRepository, FileSize, HashedFile, MediaPath,
+};
+use perima_db::{ReadPool, SqliteFileRepository, SqliteWriter};
+use perima_hash::Blake3Service;
+use rusqlite::{Connection, OpenFlags};
+use tempfile::TempDir;
+use tokio_util::sync::CancellationToken;
+
+// ---------------------------------------------------------------------------
+// Local stubs
+// ---------------------------------------------------------------------------
+
+/// No-op event bus for test writer and backfill worker.
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _: &perima_core::AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Build a temp dir-backed DB writer + file repo.
+fn test_harness() -> (TempDir, SqliteFileRepository, Connection) {
+ let tmp = TempDir::new().unwrap();
+ let db_path = tmp.path().join("perima.db");
+
+ let bus: Arc = Arc::new(NullBus);
+ let writer = SqliteWriter::start(&db_path, bus).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+ let repo = SqliteFileRepository::new(writer.sender(), reads);
+
+ // WHY open_with_flags: read-only connection for assertions only.
+ // clippy::disallowed_methods exempt: test inspection seam (see GH #131 + #124).
+ #[allow(clippy::disallowed_methods)]
+ let ro = Connection::open_with_flags(
+ &db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .unwrap();
+
+ // writer handle dropped deliberately: senders inside `repo` keep the thread alive.
+ drop(writer);
+
+ (tmp, repo, ro)
+}
+
+/// Insert a file row with `quick_hash IS NULL` via the file repo (base upsert,
+/// no `quick_hash` arg), then return its `blake3_hash`.
+///
+/// Also writes an actual file on disk at `abs_path` so the backfill worker
+/// can read bytes from it.
+fn seed_null_quick_hash_file(
+ repo: &SqliteFileRepository,
+ dev: DeviceId,
+ abs_path: &std::path::Path,
+ content: &[u8],
+ rel_name: &str,
+) -> BlakeHash {
+ // Write bytes to disk so the hasher can compute quick_hash.
+ std::fs::write(abs_path, content).unwrap();
+
+ // WHY Blake3Service: we need the exact hash the writer will store.
+ // Hashing from the file (which we just wrote) gives the same value
+ // the backfill worker's `quick_hash_prefix_suffix` will compute.
+ let hash = Blake3Service::new()
+ .full_hash(abs_path)
+ .expect("hash seed file");
+ let hf = HashedFile {
+ discovered: perima_core::DiscoveredFile {
+ absolute_path: abs_path.to_path_buf(),
+ relative_path: MediaPath::new(rel_name),
+ size: FileSize(content.len() as u64),
+ },
+ hash,
+ };
+ // WHY upsert_file (not _with_quick_hash): simulates pre-V011 insert path
+ // that left quick_hash NULL. The writer's COALESCE INSERT with None
+ // produces NULL in the column.
+ repo.upsert_file(&hf, dev).unwrap();
+
+ hash
+}
+
+/// Count rows where `quick_hash IS NULL`.
+fn count_null_quick_hash(ro: &Connection) -> i64 {
+ ro.query_row(
+ "SELECT COUNT(*) FROM files WHERE quick_hash IS NULL",
+ [],
+ |row| row.get(0),
+ )
+ .expect("COUNT null quick_hash")
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// Worker must fill every NULL `quick_hash` row it receives.
+///
+/// Setup:
+/// 1. Insert 10 files with `quick_hash IS NULL` via the base `upsert_file`.
+/// 2. Build `BackfillRow` list pointing at real on-disk files.
+/// 3. Spawn `QuickHashBackfillWorker` with `rate_per_sec = 0` (unlimited).
+/// 4. Await the `JoinHandle`.
+/// 5. Assert report.processed = 10, null count = 0.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn backfill_populates_quick_hash_for_null_rows() {
+ // WHY const before let: clippy::items_after_statements fires on const
+ // declarations that follow variable-binding statements.
+ const N: usize = 10;
+ // WHY literal not `N as i64`: clippy::cast_possible_wrap fires on
+ // const usize→i64 casts even when the value is a small literal.
+ const N_I64: i64 = 10;
+
+ let (tmp, repo, ro) = test_harness();
+ let dev = DeviceId::new();
+ let files_dir = tmp.path().join("files");
+ std::fs::create_dir_all(&files_dir).unwrap();
+
+ let mut rows: Vec = Vec::with_capacity(N);
+
+ // Seed N files with NULL quick_hash and build BackfillRow descriptors.
+ for i in 0..N {
+ let content = format!("content for file {i}");
+ let rel_name = format!("f{i:02}.txt");
+ let abs_path = files_dir.join(&rel_name);
+ let hash = seed_null_quick_hash_file(&repo, dev, &abs_path, content.as_bytes(), &rel_name);
+ rows.push(BackfillRow {
+ hash,
+ size_bytes: content.len() as u64,
+ path: abs_path,
+ });
+ }
+
+ // Confirm all 10 rows have NULL quick_hash before the worker runs.
+ assert_eq!(
+ count_null_quick_hash(&ro),
+ N_I64,
+ "all rows must start with quick_hash IS NULL"
+ );
+
+ // Spawn the backfill worker with real repo + real Blake3Service.
+ let file_repo: Arc = Arc::new(repo);
+ let hasher: Arc = Arc::new(perima_hash::Blake3Service::new());
+ let bus: Arc = Arc::new(NullBus);
+ let cancel = CancellationToken::new();
+
+ let handle = QuickHashBackfillWorker::spawn(
+ Box::new(rows.into_iter()),
+ Arc::clone(&hasher),
+ Arc::clone(&file_repo),
+ dev,
+ BackfillRate::Unlimited,
+ bus,
+ cancel,
+ );
+
+ let report: BackfillReport = handle.await.expect("worker task must not panic");
+
+ // All 10 files must be processed.
+ assert_eq!(report.processed, N as u64, "all 10 rows must be processed");
+ assert_eq!(
+ report.skipped_io_error, 0,
+ "no I/O errors expected for on-disk files"
+ );
+ assert_eq!(
+ report.skipped_no_active_location, 0,
+ "no missing-location skips (paths are supplied directly)"
+ );
+
+ // Verify the DB: no NULL quick_hash rows remain.
+ assert_eq!(
+ count_null_quick_hash(&ro),
+ 0,
+ "worker must have populated all quick_hash NULLs"
+ );
+}
+
+/// Worker must skip (warn, count) files whose path produces an I/O error.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn backfill_skips_missing_file_with_io_error() {
+ let (tmp, repo, _ro) = test_harness();
+ let dev = DeviceId::new();
+ let files_dir = tmp.path().join("skip_test");
+ std::fs::create_dir_all(&files_dir).unwrap();
+
+ // Seed one row with a NULL quick_hash, real file on disk.
+ let content = b"skip me";
+ let abs_path = files_dir.join("skip.txt");
+ let hash = seed_null_quick_hash_file(&repo, dev, &abs_path, content, "skip.txt");
+
+ // Remove the file so the hasher gets an I/O error.
+ std::fs::remove_file(&abs_path).unwrap();
+
+ let rows = vec![BackfillRow {
+ hash,
+ size_bytes: content.len() as u64,
+ path: abs_path,
+ }];
+
+ let file_repo: Arc = Arc::new(repo);
+ let hasher: Arc = Arc::new(perima_hash::Blake3Service::new());
+ let bus: Arc = Arc::new(NullBus);
+ let cancel = CancellationToken::new();
+
+ let handle = QuickHashBackfillWorker::spawn(
+ Box::new(rows.into_iter()),
+ hasher,
+ file_repo,
+ dev,
+ BackfillRate::Unlimited,
+ bus,
+ cancel,
+ );
+
+ let report = handle.await.expect("task must not panic");
+
+ assert_eq!(
+ report.processed, 0,
+ "missing file must not count as processed"
+ );
+ assert_eq!(
+ report.skipped_io_error, 1,
+ "missing file must count as skipped_io_error"
+ );
+}
+
+/// Cancellation token fires mid-run: worker drains loop and exits cleanly.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn backfill_exits_cleanly_on_cancellation() {
+ let (tmp, repo, _ro) = test_harness();
+ let dev = DeviceId::new();
+ let files_dir = tmp.path().join("cancel_test");
+ std::fs::create_dir_all(&files_dir).unwrap();
+
+ // Seed 5 files.
+ let mut rows = Vec::new();
+ for i in 0..5 {
+ let content = format!("cancel content {i}");
+ let rel = format!("c{i}.txt");
+ let abs = files_dir.join(&rel);
+ let hash = seed_null_quick_hash_file(&repo, dev, &abs, content.as_bytes(), &rel);
+ rows.push(BackfillRow {
+ hash,
+ size_bytes: content.len() as u64,
+ path: abs,
+ });
+ }
+
+ let file_repo: Arc = Arc::new(repo);
+ let hasher: Arc = Arc::new(perima_hash::Blake3Service::new());
+ let bus: Arc = Arc::new(NullBus);
+ let cancel = CancellationToken::new();
+
+ // Cancel before spawn — worker should exit without processing anything.
+ cancel.cancel();
+
+ let handle = QuickHashBackfillWorker::spawn(
+ Box::new(rows.into_iter()),
+ hasher,
+ file_repo,
+ dev,
+ BackfillRate::Unlimited,
+ bus,
+ cancel,
+ );
+
+ // Must complete (not hang).
+ let report = handle.await.expect("task must not panic");
+ // With pre-cancel, worker exits immediately — processed could be 0 or a few
+ // depending on scheduling; just verify it doesn't hang.
+ let _ = report; // outcome is non-deterministic; just verify no deadlock.
+}
diff --git a/crates/app/tests/bus.rs b/crates/app/tests/bus.rs
index 2ff658f..ef3e377 100644
--- a/crates/app/tests/bus.rs
+++ b/crates/app/tests/bus.rs
@@ -16,6 +16,7 @@ fn file_event(name: &str) -> AppEvent {
AppEvent::File(FileEvent::Created {
path: MediaPath::new(name),
volume: nil_volume(),
+ file_uuid: None,
})
}
diff --git a/crates/app/tests/data_dir_resolver.rs b/crates/app/tests/data_dir_resolver.rs
new file mode 100644
index 0000000..4bc7190
--- /dev/null
+++ b/crates/app/tests/data_dir_resolver.rs
@@ -0,0 +1,49 @@
+//! Smoke tests for the shared data-dir resolver (GH #154).
+
+#![allow(clippy::unwrap_used)] // WHY: integration test; panics are assertion failures, not prod bugs.
+
+use perima_app::config::{BUNDLE_ID, resolve_data_dir};
+
+#[test]
+fn resolve_data_dir_yields_path_with_bundle_id_segment() {
+ let path = resolve_data_dir().expect("resolve");
+ let s = path.to_string_lossy();
+ assert!(
+ s.contains(BUNDLE_ID),
+ "path {s} missing BUNDLE_ID {BUNDLE_ID}"
+ );
+}
+
+#[test]
+fn resolve_data_dir_ends_with_perima_component() {
+ let path = resolve_data_dir().expect("resolve");
+ let last = path
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ .into_owned();
+ assert_eq!(last, "perima", "path {path:?} should end in /perima");
+}
+
+#[test]
+fn resolve_data_dir_is_deterministic_within_process() {
+ let a = resolve_data_dir().expect("first");
+ let b = resolve_data_dir().expect("second");
+ assert_eq!(a, b, "resolve_data_dir must be idempotent");
+}
+
+#[test]
+fn resolve_data_dir_parent_is_bundle_id() {
+ let path = resolve_data_dir().expect("resolve");
+ let parent = path
+ .parent()
+ .expect("data_dir should have a parent")
+ .file_name()
+ .expect("parent should have a filename")
+ .to_string_lossy()
+ .into_owned();
+ assert_eq!(
+ parent, BUNDLE_ID,
+ "data_dir parent should be BUNDLE_ID, got {parent}"
+ );
+}
diff --git a/crates/app/tests/dedup_emits_verify_progress.rs b/crates/app/tests/dedup_emits_verify_progress.rs
new file mode 100644
index 0000000..8a5d57a
--- /dev/null
+++ b/crates/app/tests/dedup_emits_verify_progress.rs
@@ -0,0 +1,366 @@
+//! Verify that `ComputeFullHashUseCase::execute_batch` emits per-file
+//! `AppEvent::VerifyProgress` events followed by one `AppEvent::VerifyComplete`.
+//!
+//! Spec §4.7.3.
+
+#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs.
+#![allow(clippy::too_many_arguments)] // WHY: seed_file helper takes one arg per column.
+#![allow(clippy::too_many_lines)] // WHY: the N-file test body is assertion-heavy by design.
+
+use std::sync::{Arc, Mutex};
+
+use perima_app::ComputeFullHashUseCase;
+use perima_core::{
+ AppEvent, BatchId, BlakeHash, CoreError, DeviceId, EventBus, FileRepository, FileSize,
+ FileUuid, FullHashOutcome, HashService, HashedFile, MediaPath, VolumeId, VolumeIdentifiers,
+ VolumeRepository,
+};
+use perima_db::{ReadPool, SqliteFileRepository, SqliteVolumeRepository, SqliteWriter};
+use perima_hash::Blake3Service;
+use rusqlite::{Connection, OpenFlags};
+use tempfile::TempDir;
+
+// ---------------------------------------------------------------------------
+// Stubs
+// ---------------------------------------------------------------------------
+
+/// Event bus that records every emitted `AppEvent` for later assertion.
+#[derive(Default)]
+struct RecordingBus {
+ events: Mutex>,
+}
+
+impl EventBus for RecordingBus {
+ fn emit(&self, e: &AppEvent) -> Result<(), CoreError> {
+ self.events
+ .lock()
+ .expect("RecordingBus mutex poisoned")
+ .push(e.clone());
+ Ok(())
+ }
+}
+
+/// No-op bus for the `SqliteWriter` so its `IndexInvalidated` events don't
+/// pollute the `RecordingBus` we inspect.
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _: &AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+fn harness() -> (
+ TempDir,
+ Arc,
+ Arc,
+ Connection,
+) {
+ let tmp = TempDir::new().unwrap();
+ let db_path = tmp.path().join("perima.db");
+
+ let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let file_repo = Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let vol_repo = Arc::new(SqliteVolumeRepository::new(writer.sender(), reads));
+
+ // RO inspection connection (writer-bypass).
+ #[allow(clippy::disallowed_methods)]
+ let ro = Connection::open_with_flags(
+ &db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .unwrap();
+
+ drop(writer);
+ (tmp, file_repo, vol_repo, ro)
+}
+
+fn make_volume(
+ vol_repo: &SqliteVolumeRepository,
+ dev: DeviceId,
+ mount: &std::path::Path,
+) -> VolumeId {
+ let ids = VolumeIdentifiers {
+ gpt_partition_guid: None,
+ fs_uuid: Some(format!("test-fs-{}", uuid::Uuid::now_v7())),
+ label: Some("test".into()),
+ capacity_bytes: 1024 * 1024,
+ is_removable: false,
+ };
+ let vol = vol_repo.find_or_create(&ids, dev).unwrap();
+ vol_repo.record_mount(vol, dev, mount).unwrap();
+ vol
+}
+
+fn seed_file(
+ file_repo: &SqliteFileRepository,
+ ro: &Connection,
+ dev: DeviceId,
+ vol: VolumeId,
+ mount: &std::path::Path,
+ rel_name: &str,
+ content: &[u8],
+ quick_hash: Option,
+) -> (FileUuid, BlakeHash) {
+ let abs_path = mount.join(rel_name);
+ std::fs::write(&abs_path, content).unwrap();
+
+ let temp = mount.join(format!(".tmp_{}", uuid::Uuid::now_v7()));
+ std::fs::write(&temp, content).unwrap();
+ let hash = Blake3Service::new().full_hash(&temp).unwrap();
+ std::fs::remove_file(&temp).ok();
+
+ let hf = HashedFile {
+ discovered: perima_core::DiscoveredFile {
+ absolute_path: abs_path,
+ relative_path: MediaPath::new(rel_name),
+ size: FileSize(content.len() as u64),
+ },
+ hash,
+ };
+ file_repo
+ .upsert_file_with_quick_hash(&hf, dev, quick_hash)
+ .unwrap();
+ file_repo
+ .upsert_location(&hash, vol, &hf.discovered.relative_path, dev)
+ .unwrap();
+
+ let uuid_str: String = ro
+ .query_row(
+ "SELECT file_uuid FROM files WHERE blake3_hash = ?1",
+ [hash.to_hex()],
+ |row| row.get(0),
+ )
+ .unwrap();
+ let uuid = FileUuid(uuid::Uuid::parse_str(&uuid_str).unwrap());
+ (uuid, hash)
+}
+
+/// Poll until the recording bus contains at least one `VerifyComplete`, then
+/// return all captured events.
+async fn wait_for_complete(bus: &RecordingBus, timeout: std::time::Duration) -> Vec {
+ let deadline = std::time::Instant::now() + timeout;
+ loop {
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ let captured = bus.events.lock().unwrap().clone();
+ if captured
+ .iter()
+ .any(|e| matches!(e, AppEvent::VerifyComplete { .. }))
+ {
+ return captured;
+ }
+ assert!(
+ std::time::Instant::now() <= deadline,
+ "VerifyComplete not received within {timeout:?}",
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+/// A batch of N files must emit N `VerifyProgress` events (one per file) and
+/// one final `VerifyComplete` event, all sharing the same `batch_id`.
+///
+/// Per spec §4.7.3.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn execute_batch_emits_n_verify_progress_and_one_verify_complete() {
+ let (tmp, file_repo, vol_repo, ro) = harness();
+ let dev = DeviceId::new();
+ let mount = tmp.path().join("mount");
+ std::fs::create_dir_all(&mount).unwrap();
+ let vol = make_volume(&vol_repo, dev, &mount);
+
+ // Seed 3 files with distinct content.
+ let (uuid_a, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "a.bin",
+ b"aaa content",
+ None,
+ );
+ let (uuid_b, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "b.bin",
+ b"bbb content",
+ None,
+ );
+ let (uuid_c, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "c.bin",
+ b"ccc content",
+ None,
+ );
+
+ let recording_bus = Arc::new(RecordingBus::default());
+ let events_arc: Arc = recording_bus.clone();
+ let hasher: Arc = Arc::new(Blake3Service::new());
+ let files: Arc = file_repo;
+
+ let uc = ComputeFullHashUseCase::new(hasher, files, events_arc);
+
+ let handle = uc
+ .execute_batch(vec![uuid_a, uuid_b, uuid_c])
+ .await
+ .expect("execute_batch should not fail");
+
+ let expected_batch_id: BatchId = handle.batch_id;
+ assert_eq!(
+ handle.total, 3,
+ "BatchHandle.total must equal the number of queued files",
+ );
+
+ let captured = wait_for_complete(&recording_bus, std::time::Duration::from_secs(5)).await;
+
+ // ---- VerifyProgress assertions ------------------------------------------
+
+ let progress_events: Vec<_> = captured
+ .iter()
+ .filter(|e| matches!(e, AppEvent::VerifyProgress { .. }))
+ .collect();
+
+ assert_eq!(
+ progress_events.len(),
+ 3,
+ "must have exactly 3 VerifyProgress events (one per file); got: {captured:?}",
+ );
+
+ for (i, ev) in progress_events.iter().enumerate() {
+ if let AppEvent::VerifyProgress {
+ batch_id,
+ files_done,
+ files_total,
+ latest_outcome,
+ } = ev
+ {
+ assert_eq!(
+ *batch_id, expected_batch_id,
+ "VerifyProgress[{i}].batch_id must match BatchHandle",
+ );
+ // WHY `u32::try_from` + expect: `i` is bounded by `files_total`
+ // (at most u32::MAX) so the conversion cannot realistically fail
+ // in a unit test; `expect` is clearer than `as u32` (silent
+ // truncation on 64-bit targets triggers clippy::cast_possible_truncation).
+ let expected_done = u32::try_from(i + 1).expect("test index fits in u32");
+ assert_eq!(
+ *files_done, expected_done,
+ "VerifyProgress[{i}].files_done must be monotonically increasing",
+ );
+ assert_eq!(
+ *files_total, 3,
+ "VerifyProgress[{i}].files_total must be the batch size",
+ );
+ assert!(
+ matches!(latest_outcome, FullHashOutcome::Computed { .. }),
+ "VerifyProgress[{i}].latest_outcome must be Computed (all files are readable)",
+ );
+ }
+ }
+
+ // ---- VerifyComplete assertions ------------------------------------------
+
+ let complete_events: Vec<_> = captured
+ .iter()
+ .filter(|e| matches!(e, AppEvent::VerifyComplete { .. }))
+ .collect();
+
+ assert_eq!(
+ complete_events.len(),
+ 1,
+ "must have exactly one VerifyComplete; got: {captured:?}",
+ );
+
+ if let AppEvent::VerifyComplete { batch_id } = complete_events[0] {
+ assert_eq!(
+ *batch_id, expected_batch_id,
+ "VerifyComplete.batch_id must match BatchHandle",
+ );
+ }
+
+ // ---- Ordering assertion --------------------------------------------------
+ // All VerifyProgress events must precede the VerifyComplete.
+
+ let complete_idx = captured
+ .iter()
+ .position(|e| matches!(e, AppEvent::VerifyComplete { .. }))
+ .expect("VerifyComplete must exist");
+
+ let last_progress_idx = captured
+ .iter()
+ .rposition(|e| matches!(e, AppEvent::VerifyProgress { .. }))
+ .expect("at least one VerifyProgress must exist");
+
+ assert!(
+ last_progress_idx < complete_idx,
+ "last VerifyProgress ({last_progress_idx}) must precede VerifyComplete \
+ ({complete_idx})",
+ );
+
+ drop(tmp);
+}
+
+/// A batch with one file that cannot be located (`NotMounted`) must emit a
+/// `VerifyProgress` with `FullHashOutcome::Failed` and then `VerifyComplete`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn execute_batch_emits_failed_outcome_for_missing_file() {
+ let (tmp, file_repo, _vol_repo, _ro) = harness();
+
+ let recording_bus = Arc::new(RecordingBus::default());
+ let events_arc: Arc = recording_bus.clone();
+ let hasher: Arc = Arc::new(Blake3Service::new());
+ let files: Arc = file_repo;
+
+ let uc = ComputeFullHashUseCase::new(hasher, files, events_arc);
+
+ // A random FileUuid that has no DB row — compute_one will return NotMounted.
+ let ghost_uuid = FileUuid(uuid::Uuid::now_v7());
+
+ let handle = uc
+ .execute_batch(vec![ghost_uuid])
+ .await
+ .expect("execute_batch is infallible");
+
+ let expected_batch_id: BatchId = handle.batch_id;
+
+ let captured = wait_for_complete(&recording_bus, std::time::Duration::from_secs(5)).await;
+
+ let progress = captured
+ .iter()
+ .find(|e| matches!(e, AppEvent::VerifyProgress { .. }))
+ .expect("must have a VerifyProgress for the ghost file");
+
+ if let AppEvent::VerifyProgress {
+ batch_id,
+ files_done,
+ files_total,
+ latest_outcome,
+ } = progress
+ {
+ assert_eq!(*batch_id, expected_batch_id);
+ assert_eq!(*files_done, 1);
+ assert_eq!(*files_total, 1);
+ assert!(
+ matches!(latest_outcome, FullHashOutcome::Failed { .. }),
+ "ghost file must produce a Failed outcome; got: {latest_outcome:?}",
+ );
+ }
+
+ drop(tmp);
+}
diff --git a/crates/app/tests/dedup_use_case.rs b/crates/app/tests/dedup_use_case.rs
new file mode 100644
index 0000000..a58df10
--- /dev/null
+++ b/crates/app/tests/dedup_use_case.rs
@@ -0,0 +1,302 @@
+//! Verify `ComputeFullHashUseCase` + `DedupUseCase` end-to-end against
+//! a real `SQLite`-backed file repository.
+//!
+//! Spec §4.6 + §4.7. Tests cover:
+//! - Single `full_hash` compute persists onto the `files` row.
+//! - `list_quick_hash_collisions` groups by `quick_hash` for active rows.
+//! - `mark_verified_distinct` excludes those `file_uuids` from later listings.
+
+#![allow(clippy::unwrap_used)] // WHY: test code; unwrap panics signal bugs.
+#![allow(clippy::too_many_arguments)] // WHY: test seed helper takes one arg per column on the row.
+
+use std::sync::Arc;
+
+use perima_app::{ComputeFullHashUseCase, DedupUseCase};
+use perima_core::{
+ AppEvent, BlakeHash, CoreError, DeviceId, EventBus, FileRepository, FileSize, FileUuid,
+ HashService, HashedFile, MediaPath, VolumeId, VolumeIdentifiers, VolumeRepository,
+};
+use perima_db::{ReadPool, SqliteFileRepository, SqliteVolumeRepository, SqliteWriter};
+use perima_hash::Blake3Service;
+use rusqlite::{Connection, OpenFlags};
+use tempfile::TempDir;
+
+// ---------------------------------------------------------------------------
+// Test stubs
+// ---------------------------------------------------------------------------
+
+/// No-op event bus — the tests don't observe events; they only verify DB state.
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _: &AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+/// Build a real DB harness: tempdir, writer, file repo, volume repo, RO conn.
+fn harness() -> (
+ TempDir,
+ Arc,
+ Arc,
+ Connection,
+) {
+ let tmp = TempDir::new().unwrap();
+ let db_path = tmp.path().join("perima.db");
+
+ let bus: Arc = Arc::new(NullBus);
+ let writer = SqliteWriter::start(&db_path, bus).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let file_repo = Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let vol_repo = Arc::new(SqliteVolumeRepository::new(writer.sender(), reads));
+
+ // RO inspection connection (writer-bypass; reads only).
+ #[allow(clippy::disallowed_methods)]
+ let ro = Connection::open_with_flags(
+ &db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .unwrap();
+
+ // Drop writer handle: senders inside the repos keep the writer thread alive.
+ drop(writer);
+
+ (tmp, file_repo, vol_repo, ro)
+}
+
+/// Insert a `volumes` row + `volume_mounts` row at `mount_path` so
+/// `lookup_by_file_uuid` can resolve the absolute path.
+fn make_volume(
+ vol_repo: &SqliteVolumeRepository,
+ dev: DeviceId,
+ mount: &std::path::Path,
+) -> VolumeId {
+ let identifiers = VolumeIdentifiers {
+ gpt_partition_guid: None,
+ fs_uuid: Some(format!("test-fs-{}", uuid::Uuid::now_v7())),
+ label: Some("test-volume".into()),
+ capacity_bytes: 1024 * 1024,
+ is_removable: false,
+ };
+ let vol = vol_repo.find_or_create(&identifiers, dev).unwrap();
+ vol_repo.record_mount(vol, dev, mount).unwrap();
+ vol
+}
+
+/// Seed a file with bytes on disk + `files` row + active `file_locations` row.
+/// Returns the `file_uuid` (read back via the RO connection).
+fn seed_file(
+ file_repo: &SqliteFileRepository,
+ ro: &Connection,
+ dev: DeviceId,
+ vol: VolumeId,
+ mount: &std::path::Path,
+ rel_name: &str,
+ content: &[u8],
+ quick_hash: Option,
+) -> (FileUuid, BlakeHash) {
+ let abs_path = mount.join(rel_name);
+ std::fs::write(&abs_path, content).unwrap();
+
+ // WHY use Blake3Service to hash: avoids a direct `blake3` dev-dep in
+ // `perima-app`. The on-disk file is freshly written so a `full_hash`
+ // call against it yields the same value the test will assert later.
+ let temp_for_hash = mount.join(format!(".tmp_{}", uuid::Uuid::now_v7()));
+ std::fs::write(&temp_for_hash, content).unwrap();
+ let hash = Blake3Service::new().full_hash(&temp_for_hash).unwrap();
+ std::fs::remove_file(&temp_for_hash).ok();
+ let hf = HashedFile {
+ discovered: perima_core::DiscoveredFile {
+ absolute_path: abs_path,
+ relative_path: MediaPath::new(rel_name),
+ size: FileSize(content.len() as u64),
+ },
+ hash,
+ };
+
+ file_repo
+ .upsert_file_with_quick_hash(&hf, dev, quick_hash)
+ .unwrap();
+ file_repo
+ .upsert_location(&hash, vol, &hf.discovered.relative_path, dev)
+ .unwrap();
+
+ let uuid_str: String = ro
+ .query_row(
+ "SELECT file_uuid FROM files WHERE blake3_hash = ?1",
+ [hash.to_hex()],
+ |row| row.get(0),
+ )
+ .unwrap();
+ let uuid = FileUuid(uuid::Uuid::parse_str(&uuid_str).unwrap());
+ (uuid, hash)
+}
+
+/// Read `files.blake3_hash` for a given `file_uuid`.
+fn read_blake3_hash(ro: &Connection, file_uuid: FileUuid) -> String {
+ ro.query_row(
+ "SELECT blake3_hash FROM files WHERE file_uuid = ?1",
+ [file_uuid.0.to_string()],
+ |row| row.get(0),
+ )
+ .unwrap()
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn compute_full_hash_single_populates_files_full_hash() {
+ let (tmp, file_repo, vol_repo, ro) = harness();
+ let dev = DeviceId::new();
+ let mount = tmp.path().join("mount-a");
+ std::fs::create_dir_all(&mount).unwrap();
+ let vol = make_volume(&vol_repo, dev, &mount);
+
+ // Seed a file. Pre-promote: blake3_hash is the actual file hash since
+ // `upsert_file` uses the supplied hash. The test verifies the writer
+ // path actually overwrites the column post-promote (round-trip identity
+ // since the bytes match).
+ let content = b"hello, dedup world";
+ let (uuid, original_hash) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "a.bin",
+ content,
+ Some(BlakeHash::from_bytes([0u8; 32])),
+ );
+
+ let bus: Arc = Arc::new(NullBus);
+ let hasher: Arc = Arc::new(Blake3Service::new());
+ let files: Arc = file_repo.clone();
+ let uc = ComputeFullHashUseCase::new(hasher, files, bus);
+
+ let computed = uc.execute_single(uuid).await.expect("compute");
+ assert_eq!(
+ computed, original_hash,
+ "compute_full_hash must return the BLAKE3 of the file bytes"
+ );
+
+ // Verify the writer landed the new hash on the row.
+ let stored_hex = read_blake3_hash(&ro, uuid);
+ assert_eq!(
+ stored_hex,
+ original_hash.to_hex(),
+ "files.blake3_hash must be promoted to the freshly-computed value",
+ );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn list_quick_hash_collisions_groups_files_with_matching_quick_hash() {
+ let (tmp, file_repo, vol_repo, ro) = harness();
+ let dev = DeviceId::new();
+ let mount = tmp.path().join("mount-b");
+ std::fs::create_dir_all(&mount).unwrap();
+ let vol = make_volume(&vol_repo, dev, &mount);
+
+ // Two files share quick_hash QH1; one file has a unique quick_hash QH2.
+ let qh_shared = BlakeHash::from_bytes([0xAA; 32]);
+ let qh_unique = BlakeHash::from_bytes([0xBB; 32]);
+
+ let (_uuid_a, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "shared_a.bin",
+ b"alpha bytes",
+ Some(qh_shared),
+ );
+ let (_uuid_b, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "shared_b.bin",
+ b"beta bytes",
+ Some(qh_shared),
+ );
+ let (_uuid_c, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "lonely.bin",
+ b"lonely bytes",
+ Some(qh_unique),
+ );
+
+ let bus: Arc = Arc::new(NullBus);
+ let files: Arc = file_repo;
+ let dedup = DedupUseCase::new(files, bus);
+
+ let groups = dedup.list_collisions().expect("list_collisions");
+ assert_eq!(groups.len(), 1, "exactly one collision group expected");
+ let group = &groups[0];
+ assert_eq!(group.quick_hash, qh_shared);
+ assert_eq!(group.files.len(), 2, "shared group must contain both files");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn mark_verified_distinct_excludes_files_from_subsequent_collision_lists() {
+ let (tmp, file_repo, vol_repo, ro) = harness();
+ let dev = DeviceId::new();
+ let mount = tmp.path().join("mount-c");
+ std::fs::create_dir_all(&mount).unwrap();
+ let vol = make_volume(&vol_repo, dev, &mount);
+
+ let qh_shared = BlakeHash::from_bytes([0xCC; 32]);
+ let (uuid_a, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "verify_a.bin",
+ b"a contents",
+ Some(qh_shared),
+ );
+ let (uuid_b, _) = seed_file(
+ &file_repo,
+ &ro,
+ dev,
+ vol,
+ &mount,
+ "verify_b.bin",
+ b"b contents",
+ Some(qh_shared),
+ );
+
+ let bus: Arc = Arc::new(NullBus);
+ let files: Arc = file_repo;
+ let dedup = DedupUseCase::new(files, bus);
+
+ // Sanity: pre-mark, the group surfaces.
+ let pre = dedup.list_collisions().unwrap();
+ assert_eq!(pre.len(), 1, "pre-mark: one collision group expected");
+
+ // Mark both as verified-distinct.
+ dedup
+ .mark_verified_distinct(vec![uuid_a, uuid_b], dev)
+ .expect("mark_verified_distinct");
+
+ // Post-mark: no group surfaces (both rows have verified_distinct = 1, so
+ // the GROUP BY filters them out).
+ let post = dedup.list_collisions().unwrap();
+ assert_eq!(
+ post.len(),
+ 0,
+ "post-mark: verified_distinct rows must not surface as collisions"
+ );
+}
diff --git a/crates/app/tests/handler_spawn.rs b/crates/app/tests/handler_spawn.rs
index 3071846..05ad87e 100644
--- a/crates/app/tests/handler_spawn.rs
+++ b/crates/app/tests/handler_spawn.rs
@@ -20,6 +20,7 @@ fn file_event(name: &str) -> AppEvent {
AppEvent::File(FileEvent::Created {
path: MediaPath::new(name),
volume: nil_volume(),
+ file_uuid: None,
})
}
diff --git a/crates/app/tests/list_files_with_tags_includes_file_uuid.rs b/crates/app/tests/list_files_with_tags_includes_file_uuid.rs
new file mode 100644
index 0000000..c5960ad
--- /dev/null
+++ b/crates/app/tests/list_files_with_tags_includes_file_uuid.rs
@@ -0,0 +1,141 @@
+//! Task 11 (spec §4.8): `TagCommand::ListFilesWithTags` must surface
+//! `file_uuid` on every returned `FileWithTags.location`.
+//!
+//! WHY this test matters: post-Task-11, the IPC payload pivots to
+//! `file_uuid` as the stable surrogate key. Pending files (no `full_hash`
+//! computed yet) have `hash: None` but `file_uuid` is always present so
+//! UI / FK lookups still work. Re-introducing a non-nullable `hash` or
+//! removing `file_uuid` is a regression caught here.
+
+#![allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs.
+
+use std::io::Write as _;
+use std::sync::Arc;
+
+use perima_app::{
+ FullScan, ScanCommand, ScanUseCase, TagCommand, TagFilter, TagOutput, TagUseCase,
+};
+use perima_core::{
+ AppEvent, CoreError, EventBus, FileRepository, HashService, IdentityCacheRepository,
+ MetadataRepository, Scanner, TagRepository, VolumeRepository,
+};
+use perima_db::{
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteTagRepository, SqliteVolumeRepository, SqliteWriter,
+};
+use perima_fs::WalkdirScanner;
+use perima_hash::Blake3Service;
+use perima_media::ThumbnailGenerator;
+use tempfile::TempDir;
+use tokio_util::sync::CancellationToken;
+
+/// No-op event bus — keeps the writer happy without intercepting events.
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _: &AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+fn mk_fixture(dir: &std::path::Path) {
+ let path = dir.join("only.txt");
+ std::fs::File::create(&path)
+ .unwrap()
+ .write_all(b"only")
+ .unwrap();
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn list_files_with_tags_payload_carries_file_uuid_and_nullable_hash() {
+ // ---- wire-up -----------------------------------------------------------
+ let db_tmp = TempDir::new().unwrap();
+ let fixture = TempDir::new().unwrap();
+ mk_fixture(fixture.path());
+
+ let db_path = db_tmp.path().join("perima.db");
+ let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let files: Arc =
+ Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let volumes: Arc =
+ Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone()));
+ let metadata: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc = Arc::new(SqliteIdentityCacheRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let tags: Arc = Arc::new(SqliteTagRepository::new(writer.sender(), reads));
+
+ let scanner: Arc = Arc::new(WalkdirScanner::new());
+ let hasher: Arc = Arc::new(Blake3Service::new());
+ let thumbnailer = Arc::new(ThumbnailGenerator::disabled());
+ let events: Arc = Arc::new(NullBus);
+
+ // Run a scan to seed the DB.
+ let uc = ScanUseCase::new(
+ Arc::clone(&files),
+ Arc::clone(&volumes),
+ Arc::clone(&metadata),
+ Arc::clone(&cache),
+ scanner,
+ hasher,
+ thumbnailer,
+ Arc::clone(&events),
+ );
+ let device_id = perima_core::DeviceId::new();
+ let cmd = ScanCommand::Full(FullScan {
+ path: fixture.path().to_path_buf(),
+ device_id,
+ with_metadata: false,
+ dry_run: false,
+ no_wait_metadata: true,
+ no_thumbnails: true,
+ cancel: CancellationToken::new(),
+ on_persist: None,
+ });
+ let report = uc.execute(cmd).await.expect("scan should succeed");
+ assert_eq!(report.files_seen, 1, "fixture seeded with 1 file");
+
+ // ---- act --------------------------------------------------------------
+ let tag_uc = TagUseCase::new(Arc::clone(&tags), Arc::clone(&metadata), events);
+ let out = tag_uc
+ .execute(TagCommand::ListFilesWithTags {
+ filter: Some(TagFilter {
+ limit: 100,
+ volume: None,
+ }),
+ })
+ .await
+ .expect("list_files_with_tags");
+ let TagOutput::FilesWithTags(rows) = out else {
+ panic!("unexpected TagOutput variant");
+ };
+
+ // ---- assert -----------------------------------------------------------
+ assert!(!rows.is_empty(), "scan must have produced at least one row");
+ let row = &rows[0];
+ // file_uuid is always present (stable surrogate, populated in V011).
+ assert_ne!(
+ row.location.file_uuid.0,
+ uuid::Uuid::nil(),
+ "file_uuid must be populated post-Task-11 (got nil UUID)",
+ );
+ // Hash is Some(...) for files that completed full hashing during scan.
+ // For pending files (post-Task-9 dedup batches that haven't computed
+ // full_hash yet) the hash is None. The scan path always populates it.
+ assert!(
+ row.location.hash.is_some(),
+ "scanner-inserted row must carry full_hash (hash type pivoted to Option in Task 11)",
+ );
+
+ drop(tags);
+ drop(files);
+ drop(volumes);
+ drop(metadata);
+ drop(cache);
+ writer.join();
+}
diff --git a/crates/app/tests/scan_emits_completed.rs b/crates/app/tests/scan_emits_completed.rs
index a0f69f4..1c18537 100644
--- a/crates/app/tests/scan_emits_completed.rs
+++ b/crates/app/tests/scan_emits_completed.rs
@@ -8,11 +8,12 @@ use std::sync::{Arc, Mutex};
use perima_app::{FullScan, ScanCommand, ScanUseCase};
use perima_core::{
- AppEvent, CoreError, EventBus, FileRepository, HashService, MetadataRepository, Scanner,
- VolumeRepository,
+ AppEvent, CoreError, EventBus, FileRepository, HashService, IdentityCacheRepository,
+ MetadataRepository, Scanner, VolumeRepository,
};
use perima_db::{
- ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteVolumeRepository, SqliteWriter,
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteVolumeRepository, SqliteWriter,
};
use perima_fs::WalkdirScanner;
use perima_hash::Blake3Service;
@@ -94,8 +95,12 @@ async fn scan_use_case_emits_scan_completed_on_success() {
Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
let volumes: Arc =
Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone()));
- let metadata: Arc =
- Arc::new(SqliteMetadataRepository::new(writer.sender(), reads));
+ let metadata: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
let scanner: Arc = Arc::new(WalkdirScanner::new());
let hasher: Arc = Arc::new(Blake3Service::new());
@@ -110,6 +115,7 @@ async fn scan_use_case_emits_scan_completed_on_success() {
files,
volumes,
metadata,
+ cache,
scanner,
hasher,
thumbnailer,
diff --git a/crates/app/tests/scan_uses_tier0_cache.rs b/crates/app/tests/scan_uses_tier0_cache.rs
new file mode 100644
index 0000000..4dbb069
--- /dev/null
+++ b/crates/app/tests/scan_uses_tier0_cache.rs
@@ -0,0 +1,361 @@
+//! Verify that `ScanUseCase::execute_full` consults the Tier-0 identity cache
+//! BEFORE hashing each file.
+//!
+//! Spec §4.3 (Tier-0 cache logic) + plan Task 7. On a cache hit the use case
+//! MUST skip the hash compute entirely — no `quick_hash`, `quick_hash_prefix_suffix`,
+//! or `full_hash` invocation.
+//!
+//! WHY this is the canonical performance landing test: the whole rationale for
+//! the V011 cache table is "skip the file read on re-scans of unchanged files".
+//! A regression that re-introduces the hash call (e.g. someone refactors the
+//! cache lookup back into a no-op) would silently undo the v0.6.x perf goal;
+//! this test catches that immediately by counting hash invocations on the mock.
+
+#![allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs.
+
+use std::io::Write as _;
+use std::path::Path;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use perima_app::{FullScan, ScanCommand, ScanUseCase};
+use perima_core::{
+ AppEvent, BlakeHash, CacheEntry, CacheKey, CoreError, DeviceId, EventBus, FileRepository,
+ HashService, IdentityCacheRepository, MetadataRepository, Scanner, VolumeRepository,
+};
+use perima_db::{
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteVolumeRepository, SqliteWriter,
+};
+use perima_fs::WalkdirScanner;
+use perima_media::ThumbnailGenerator;
+use rusqlite::{Connection, OpenFlags};
+use tempfile::TempDir;
+use tokio_util::sync::CancellationToken;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// `HashService` mock that counts every method call.
+///
+/// Tier-0 cache hits MUST skip every hash call; a non-zero count after a
+/// cache-pre-populated scan is a regression.
+#[derive(Default)]
+#[allow(clippy::struct_field_names)] // every field IS a per-method counter; the suffix is intentional
+struct CountingHasher {
+ quick_calls: AtomicUsize,
+ quick_ps_calls: AtomicUsize,
+ full_calls: AtomicUsize,
+ full_dispatched_calls: AtomicUsize,
+}
+
+impl CountingHasher {
+ fn new() -> Arc {
+ Arc::new(Self::default())
+ }
+
+ fn total_calls(&self) -> usize {
+ self.quick_calls.load(Ordering::SeqCst)
+ + self.quick_ps_calls.load(Ordering::SeqCst)
+ + self.full_calls.load(Ordering::SeqCst)
+ + self.full_dispatched_calls.load(Ordering::SeqCst)
+ }
+}
+
+impl HashService for CountingHasher {
+ fn quick_hash(&self, _path: &Path) -> Result {
+ self.quick_calls.fetch_add(1, Ordering::SeqCst);
+ Ok(BlakeHash::from_bytes([0u8; 32]))
+ }
+
+ fn full_hash(&self, _path: &Path) -> Result {
+ self.full_calls.fetch_add(1, Ordering::SeqCst);
+ Ok(BlakeHash::from_bytes([0u8; 32]))
+ }
+
+ fn full_hash_dispatched(
+ &self,
+ _path: &Path,
+ _size_bytes: u64,
+ _device_kind: perima_core::DeviceKind,
+ ) -> Result {
+ self.full_dispatched_calls.fetch_add(1, Ordering::SeqCst);
+ Ok(BlakeHash::from_bytes([0u8; 32]))
+ }
+
+ fn quick_hash_prefix_suffix(
+ &self,
+ _path: &Path,
+ _size_bytes: u64,
+ ) -> Result {
+ self.quick_ps_calls.fetch_add(1, Ordering::SeqCst);
+ Ok(BlakeHash::from_bytes([0u8; 32]))
+ }
+}
+
+/// No-op event bus for the writer + use case.
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _e: &AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+/// Fixture: write a single small file so the walker yields exactly one entry.
+fn mk_one_file(dir: &Path) {
+ let p = dir.join("alpha.txt");
+ std::fs::File::create(&p)
+ .unwrap()
+ .write_all(b"alpha-content")
+ .unwrap();
+}
+
+// ---------------------------------------------------------------------------
+// Test
+// ---------------------------------------------------------------------------
+
+/// Tier-0 cache hit must short-circuit all hash calls.
+///
+/// Setup: pre-populate `file_identity_cache` with a row whose lookup tuple
+/// `(device, volume, fs_file_id, size, mtime_ns)` matches the on-disk file.
+/// Run a full scan with a `CountingHasher`; assert zero hash invocations.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cache_hit_skips_all_hash_calls() {
+ // ---- wire-up ------------------------------------------------------------
+ let db_tmp = TempDir::new().unwrap();
+ let fixture = TempDir::new().unwrap();
+ mk_one_file(fixture.path());
+
+ let db_path = db_tmp.path().join("perima.db");
+ let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let files: Arc =
+ Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let volumes: Arc =
+ Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone()));
+ let metadata: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
+
+ let scanner: Arc = Arc::new(WalkdirScanner::new());
+ let counting = CountingHasher::new();
+ let hasher: Arc = counting.clone();
+ let thumbnailer = Arc::new(ThumbnailGenerator::disabled());
+ let events: Arc = Arc::new(NullBus);
+
+ let device_id = DeviceId::new();
+
+ // Resolve the volume up-front so we can pre-populate the cache row with the
+ // SAME volume_id that ScanUseCase will derive at scan time. `find_or_create`
+ // is idempotent — the second resolution at scan time returns the same id.
+ let detected = perima_fs::detect_volume(fixture.path()).unwrap();
+ let volume_id = volumes
+ .find_or_create(&detected.identifiers, device_id)
+ .unwrap();
+
+ // Stat the fixture file via `Scanner::stat_with_id` so the cache row's
+ // lookup tuple (size, mtime_ns, fs_file_id) matches what the use case
+ // computes during the scan.
+ let file_path = fixture.path().join("alpha.txt");
+ let stat = scanner.stat_with_id(&file_path).unwrap();
+
+ // Pre-populate the cache. WHY arbitrary non-zero hash bytes: the cached
+ // BlakeHash content doesn't matter for this test — we only assert that
+ // ScanUseCase skipped the hasher. Use a recognizable pattern (0xAB) so a
+ // future debug print is unambiguous.
+ let cached_quick = BlakeHash::from_bytes([0xABu8; 32]);
+ let key = CacheKey {
+ device_id,
+ volume_id,
+ fs_file_id: stat.fs_file_id,
+ size_bytes: stat.size_bytes,
+ mtime_ns: stat.mtime_ns,
+ };
+ let entry = CacheEntry {
+ quick_hash: cached_quick,
+ full_hash: None,
+ };
+ cache.upsert(&key, &entry).unwrap();
+
+ let uc = ScanUseCase::new(
+ files,
+ volumes,
+ metadata,
+ cache,
+ scanner,
+ hasher,
+ thumbnailer,
+ events,
+ );
+
+ // ---- execute ------------------------------------------------------------
+ let cmd = ScanCommand::Full(FullScan {
+ path: fixture.path().to_path_buf(),
+ device_id,
+ with_metadata: false,
+ dry_run: false,
+ no_wait_metadata: true,
+ no_thumbnails: true,
+ cancel: CancellationToken::new(),
+ on_persist: None,
+ });
+
+ let report = uc.execute(cmd).await.expect("scan succeeds");
+
+ // ---- assert -------------------------------------------------------------
+ assert_eq!(report.files_seen, 1, "fixture has exactly one file");
+ assert_eq!(report.files_errored, 0, "no file errors expected");
+
+ // The crux of Task 7: a cache HIT must skip every hash invocation.
+ assert_eq!(
+ counting.total_calls(),
+ 0,
+ "Tier-0 cache hit must skip every HashService call (got quick={} quick_ps={} full={} full_dispatched={})",
+ counting.quick_calls.load(Ordering::SeqCst),
+ counting.quick_ps_calls.load(Ordering::SeqCst),
+ counting.full_calls.load(Ordering::SeqCst),
+ counting.full_dispatched_calls.load(Ordering::SeqCst),
+ );
+
+ // Spec §4.1.1 + Task 7 fix (commit d7161f0): cache-HIT path must also
+ // populate `files.quick_hash` (using the cached entry's quick_hash),
+ // not just `file_identity_cache.quick_hash`. Without this assertion a
+ // regression dropping the hit-side `Some(entry.quick_hash)` to `None`
+ // would silently slip past — the writer-level tests don't exercise
+ // the scan-loop wiring. Mirror of the analogous assertion in
+ // `cache_miss_calls_quick_hash_prefix_suffix`.
+ let conn = Connection::open_with_flags(
+ &db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .unwrap();
+ let count: i64 = conn
+ .query_row(
+ "SELECT COUNT(*) FROM files WHERE quick_hash IS NOT NULL",
+ [],
+ |r| r.get(0),
+ )
+ .unwrap();
+ assert_eq!(
+ count, 1,
+ "cache-HIT path must populate files.quick_hash for the row it inserts"
+ );
+ drop(conn);
+
+ // WHY explicit drop order: writer outlives repo handles; tempdirs last.
+ drop(writer);
+ drop(db_tmp);
+ drop(fixture);
+}
+
+/// Tier-0 cache MISS must compute `quick_hash_prefix_suffix` exactly once per
+/// file and insert a fresh cache row.
+///
+/// WHY pair this test with the hit: alone, the hit test would silently pass if
+/// `ScanUseCase` started returning early on every file (e.g. a `return Ok(report)`
+/// placed before the loop). The miss test confirms the use case actually
+/// processes files when there's no cached entry.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn cache_miss_calls_quick_hash_prefix_suffix() {
+ let db_tmp = TempDir::new().unwrap();
+ let fixture = TempDir::new().unwrap();
+ mk_one_file(fixture.path());
+
+ let db_path = db_tmp.path().join("perima.db");
+ let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let files: Arc =
+ Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let volumes: Arc =
+ Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone()));
+ let metadata: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
+
+ let scanner: Arc = Arc::new(WalkdirScanner::new());
+ let counting = CountingHasher::new();
+ let hasher: Arc = counting.clone();
+ let thumbnailer = Arc::new(ThumbnailGenerator::disabled());
+ let events: Arc = Arc::new(NullBus);
+
+ let uc = ScanUseCase::new(
+ files,
+ volumes,
+ metadata,
+ Arc::clone(&cache),
+ scanner,
+ hasher,
+ thumbnailer,
+ events,
+ );
+
+ let device_id = DeviceId::new();
+ let cmd = ScanCommand::Full(FullScan {
+ path: fixture.path().to_path_buf(),
+ device_id,
+ with_metadata: false,
+ dry_run: false,
+ no_wait_metadata: true,
+ no_thumbnails: true,
+ cancel: CancellationToken::new(),
+ on_persist: None,
+ });
+
+ let report = uc.execute(cmd).await.expect("scan succeeds");
+ assert_eq!(report.files_seen, 1);
+
+ // Miss path: exactly one quick_hash_prefix_suffix call. Other hash methods
+ // must remain at 0 so a future regression that swaps in `full_hash` (or
+ // the legacy `quick_hash`) under the miss path lights up here.
+ assert_eq!(
+ counting.quick_ps_calls.load(Ordering::SeqCst),
+ 1,
+ "miss path must call quick_hash_prefix_suffix exactly once",
+ );
+ assert_eq!(
+ counting.quick_calls.load(Ordering::SeqCst),
+ 0,
+ "miss path must not fall back to legacy quick_hash",
+ );
+ assert_eq!(
+ counting.full_calls.load(Ordering::SeqCst)
+ + counting.full_dispatched_calls.load(Ordering::SeqCst),
+ 0,
+ "miss path must not full-hash in v0.6.x — full_hash is Task 9 work",
+ );
+
+ // Spec §4.1.1: files.quick_hash must be populated after a cache-miss scan.
+ // WHY raw SQL: the port-trait read path does not expose quick_hash yet
+ // (Task 9 adds list_quick_hash_collisions). A direct SELECT verifies the
+ // writer actually wrote the column.
+ let ro = Connection::open_with_flags(
+ &db_path,
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
+ )
+ .unwrap();
+ let quick_hash_count: i64 = ro
+ .query_row(
+ "SELECT COUNT(*) FROM files WHERE quick_hash IS NOT NULL",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(
+ quick_hash_count, 1,
+ "spec §4.1.1: files.quick_hash must be non-NULL after a cache-miss scan \
+ (got {quick_hash_count} rows with quick_hash IS NOT NULL)"
+ );
+
+ drop(writer);
+ drop(db_tmp);
+ drop(fixture);
+}
diff --git a/crates/app/tests/search_includes_file_uuid.rs b/crates/app/tests/search_includes_file_uuid.rs
new file mode 100644
index 0000000..17739dc
--- /dev/null
+++ b/crates/app/tests/search_includes_file_uuid.rs
@@ -0,0 +1,127 @@
+//! Task 11 (spec §4.8): `SearchHit` must surface `file_uuid` for every result.
+//!
+//! `blake3_hash` becomes `Option` in the same change (pending files
+//! with no `full_hash` still hit the FTS index). This test pins the wire
+//! shape with one assertion per field: `file_uuid` is non-empty UUID,
+//! `blake3_hash` is `Some(_)` for a scan-seeded row.
+
+#![allow(clippy::unwrap_used)] // Test code; unwrap panics signal bugs.
+
+use std::io::Write as _;
+use std::sync::Arc;
+
+use perima_app::{FullScan, ScanCommand, ScanUseCase, SearchCommand, SearchUseCase};
+use perima_core::{
+ AppEvent, CoreError, EventBus, FileRepository, HashService, IdentityCacheRepository,
+ MetadataRepository, Scanner, SearchRepository, VolumeRepository,
+};
+use perima_db::{
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteSearchRepository, SqliteVolumeRepository, SqliteWriter,
+};
+use perima_fs::WalkdirScanner;
+use perima_hash::Blake3Service;
+use perima_media::ThumbnailGenerator;
+use tempfile::TempDir;
+use tokio_util::sync::CancellationToken;
+
+struct NullBus;
+impl EventBus for NullBus {
+ fn emit(&self, _: &AppEvent) -> Result<(), CoreError> {
+ Ok(())
+ }
+}
+
+fn mk_fixture(dir: &std::path::Path) {
+ // WHY a recognisable filename: gives a deterministic FTS5 token to query.
+ let path = dir.join("vacation_paris_2024.txt");
+ std::fs::File::create(&path)
+ .unwrap()
+ .write_all(b"vacation")
+ .unwrap();
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn search_hit_carries_file_uuid_and_nullable_hash() {
+ let db_tmp = TempDir::new().unwrap();
+ let fixture = TempDir::new().unwrap();
+ mk_fixture(fixture.path());
+
+ let db_path = db_tmp.path().join("perima.db");
+ let writer = SqliteWriter::start(&db_path, Arc::new(NullBus)).unwrap();
+ let reads = ReadPool::open(&db_path).unwrap();
+
+ let files: Arc =
+ Arc::new(SqliteFileRepository::new(writer.sender(), reads.clone()));
+ let volumes: Arc =
+ Arc::new(SqliteVolumeRepository::new(writer.sender(), reads.clone()));
+ let metadata: Arc = Arc::new(SqliteMetadataRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let cache: Arc = Arc::new(SqliteIdentityCacheRepository::new(
+ writer.sender(),
+ reads.clone(),
+ ));
+ let search: Arc =
+ Arc::new(SqliteSearchRepository::new(writer.sender(), reads));
+
+ let scanner: Arc = Arc::new(WalkdirScanner::new());
+ let hasher: Arc = Arc::new(Blake3Service::new());
+ let thumbnailer = Arc::new(ThumbnailGenerator::disabled());
+ let events: Arc = Arc::new(NullBus);
+
+ // Seed via scan (populates files, file_locations, search_content via triggers).
+ let uc = ScanUseCase::new(
+ Arc::clone(&files),
+ Arc::clone(&volumes),
+ Arc::clone(&metadata),
+ Arc::clone(&cache),
+ scanner,
+ hasher,
+ thumbnailer,
+ Arc::clone(&events),
+ );
+ let device_id = perima_core::DeviceId::new();
+ let cmd = ScanCommand::Full(FullScan {
+ path: fixture.path().to_path_buf(),
+ device_id,
+ with_metadata: false,
+ dry_run: false,
+ no_wait_metadata: true,
+ no_thumbnails: true,
+ cancel: CancellationToken::new(),
+ on_persist: None,
+ });
+ uc.execute(cmd).await.expect("scan");
+
+ // ---- act --------------------------------------------------------------
+ let search_uc = SearchUseCase::new(Arc::clone(&search), events);
+ let out = search_uc
+ .execute(SearchCommand::Query {
+ q: "vacation".into(),
+ limit: None,
+ })
+ .await
+ .expect("search query");
+
+ // ---- assert -----------------------------------------------------------
+ assert!(!out.hits.is_empty(), "expected at least one search hit");
+ let hit = &out.hits[0];
+ assert_ne!(
+ hit.file_uuid.0,
+ uuid::Uuid::nil(),
+ "file_uuid must be populated post-Task-11 (got nil UUID)",
+ );
+ assert!(
+ hit.blake3_hash.is_some(),
+ "scan-seeded row must carry full_hash (Option shape post-Task-11)",
+ );
+
+ drop(search);
+ drop(files);
+ drop(volumes);
+ drop(metadata);
+ drop(cache);
+ writer.join();
+}
diff --git a/crates/cli/src/cmd/dedup.rs b/crates/cli/src/cmd/dedup.rs
new file mode 100644
index 0000000..e8f1f5f
--- /dev/null
+++ b/crates/cli/src/cmd/dedup.rs
@@ -0,0 +1,215 @@
+//! `perima dedup` subcommand — CLI counterpart of the `/dedup` desktop route.
+//!
+//! Three modes:
+//! - `perima dedup check` — list candidate groups (files sharing `quick_hash`).
+//! - `perima dedup verify` — list candidates + compute full hash on each group.
+//! - `perima dedup mark-distinct ...` — mark files as verified-distinct.
+
+use std::path::Path;
+
+use perima_app::AppContainer;
+use perima_core::{CoreError, DeviceId, FileUuid};
+
+/// Arguments for the `perima dedup` command.
+#[derive(clap::Args, Debug)]
+pub(crate) struct DedupArgs {
+ /// The dedup action to perform.
+ #[command(subcommand)]
+ pub action: DedupAction,
+}
+
+/// Individual actions available under `perima dedup`.
+#[derive(clap::Subcommand, Debug)]
+pub(crate) enum DedupAction {
+ /// Print all candidate duplicate groups (files sharing `quick_hash`).
+ ///
+ /// Each group is printed as a section with the shared `quick_hash` fingerprint
+ /// followed by one line per file showing path and size.
+ Check,
+
+ /// Print candidate groups AND compute full hash on every file in each group
+ /// to confirm or deny true duplication.
+ ///
+ /// Files that fail hashing (not mounted, I/O error) are reported as errors
+ /// but do not abort the run. After verification, groups are labelled
+ /// "TRUE DUPLICATE" or "FALSE POSITIVE" based on full-hash equality.
+ Verify,
+
+ /// Memorise that the listed file UUIDs share a `quick_hash` but are verified
+ /// distinct (full hashes differ). They will be excluded from future
+ /// `check` and `verify` output.
+ #[command(name = "mark-distinct")]
+ MarkDistinct {
+ /// UUIDs of files to mark as verified-distinct.
+ /// Must include every file in the group to suppress the group.
+ #[arg(required = true, num_args = 1..)]
+ file_uuids: Vec,
+ },
+}
+
+/// Execute `perima dedup `.
+///
+/// # Errors
+/// Propagates [`CoreError`] from the underlying use-case ports.
+pub(crate) async fn run(
+ container: &AppContainer,
+ _data_dir: &Path,
+ device: DeviceId,
+ args: &DedupArgs,
+) -> Result<(), CoreError> {
+ match &args.action {
+ DedupAction::Check => run_check(container),
+ DedupAction::Verify => run_verify(container).await,
+ DedupAction::MarkDistinct { file_uuids } => {
+ run_mark_distinct(container, device, file_uuids)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// check
+// ---------------------------------------------------------------------------
+
+/// `perima dedup check` — print all candidate duplicate groups.
+fn run_check(container: &AppContainer) -> Result<(), CoreError> {
+ let groups = container.dedup.list_collisions()?;
+
+ if groups.is_empty() {
+ println!("no duplicate candidates found");
+ return Ok(());
+ }
+
+ println!("{} candidate group(s):", groups.len());
+
+ for (i, group) in groups.iter().enumerate() {
+ println!();
+ println!(
+ "--- group {} of {} --- quick_hash: {} state: {:?}",
+ i + 1,
+ groups.len(),
+ group.quick_hash.to_hex(),
+ group.verified_state
+ );
+
+ for file in &group.files {
+ let uuid = file.file_uuid.0;
+ let path = file.relative_path.as_str();
+ let size = file.size.0;
+ let hash_str = file
+ .hash
+ .as_ref()
+ .map_or_else(|| "(pending)".to_owned(), perima_core::BlakeHash::to_hex);
+ println!(" [{uuid}] {path} {size} bytes full_hash: {hash_str}");
+ }
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// verify
+// ---------------------------------------------------------------------------
+
+/// `perima dedup verify` — list candidates + compute full hash on each.
+///
+/// WHY sequential per-file hashing: avoids parallel disk thrash on HDDs
+/// (spec §4.5). Groups are processed one at a time; within each group files
+/// are hashed sequentially.
+async fn run_verify(container: &AppContainer) -> Result<(), CoreError> {
+ let groups = container.dedup.list_collisions()?;
+
+ if groups.is_empty() {
+ println!("no duplicate candidates found");
+ return Ok(());
+ }
+
+ println!(
+ "{} candidate group(s) — verifying by full hash:",
+ groups.len()
+ );
+
+ for (i, group) in groups.iter().enumerate() {
+ println!(
+ "\n--- group {} of {} --- quick_hash: {}",
+ i + 1,
+ groups.len(),
+ group.quick_hash.to_hex()
+ );
+
+ // Collect the full hashes for each file in the group.
+ let mut full_hashes: Vec> = Vec::with_capacity(group.files.len());
+
+ for file in &group.files {
+ match container
+ .compute_full_hash
+ .execute_single(file.file_uuid)
+ .await
+ {
+ Ok(hash) => {
+ let hash_hex = hash.to_hex();
+ println!(
+ " [{}] {} full_hash: {}",
+ file.file_uuid.0,
+ file.relative_path.as_str(),
+ hash_hex
+ );
+ full_hashes.push(Some(hash_hex));
+ }
+ Err(e) => {
+ eprintln!(
+ " [{}] {} ERROR: {e}",
+ file.file_uuid.0,
+ file.relative_path.as_str()
+ );
+ full_hashes.push(None);
+ }
+ }
+ }
+
+ // Determine verification outcome for this group.
+ let computed: Vec<&str> = full_hashes.iter().filter_map(|h| h.as_deref()).collect();
+
+ let verdict = if computed.is_empty() {
+ "UNVERIFIABLE (all files failed to hash)"
+ } else {
+ let first = computed[0];
+ let all_match = computed.iter().all(|h| *h == first);
+ if all_match {
+ "TRUE DUPLICATE"
+ } else {
+ "FALSE POSITIVE (full hashes differ)"
+ }
+ };
+
+ println!(" => {verdict}");
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// mark-distinct
+// ---------------------------------------------------------------------------
+
+/// `perima dedup mark-distinct ...` — mark files as verified-distinct.
+fn run_mark_distinct(
+ container: &AppContainer,
+ device: DeviceId,
+ raw_uuids: &[String],
+) -> Result<(), CoreError> {
+ let file_uuids: Result, CoreError> = raw_uuids
+ .iter()
+ .map(|s| {
+ uuid::Uuid::parse_str(s)
+ .map(FileUuid)
+ .map_err(|e| CoreError::InvalidPath(format!("bad file UUID '{s}': {e}")))
+ })
+ .collect();
+ let file_uuids = file_uuids?;
+ let count = file_uuids.len();
+
+ container.dedup.mark_verified_distinct(file_uuids, device)?;
+
+ println!("marked {count} file(s) as verified-distinct");
+ Ok(())
+}
diff --git a/crates/cli/src/cmd/hash.rs b/crates/cli/src/cmd/hash.rs
new file mode 100644
index 0000000..3becf35
--- /dev/null
+++ b/crates/cli/src/cmd/hash.rs
@@ -0,0 +1,246 @@
+//! `perima hash` subcommand — on-demand full-hash computation for indexed files.
+//!
+//! Three modes:
+//! - `perima hash ` — compute + print full BLAKE3 hash for one file.
+//! - `perima hash --all` — compute full hash for every indexed file (opt-in slow).
+//! - `perima hash --pending` — compute full hash only for files whose
+//! `blake3_hash` is `NULL` (not yet computed, per V011 nullable column).
+
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+
+use perima_app::AppContainer;
+use perima_core::{CoreError, DeviceId, DeviceKind, FileUuid, FullHashUnavailableReason};
+
+use super::metadata::find_by_absolute_suffix;
+
+/// Arguments for the `perima hash` command.
+#[derive(clap::Args, Debug)]
+pub(crate) struct HashArgs {
+ /// Path of a single file to hash. Exclusive with --all and --pending.
+ pub path: Option,
+
+ /// Compute full hash for every indexed file. Sequential; may be slow on
+ /// large libraries. Exclusive with --pending and a positional path.
+ #[arg(long, conflicts_with_all = ["path", "pending"])]
+ pub all: bool,
+
+ /// Compute full hash only for files whose `full_hash` is not yet set.
+ /// Uses the pending-full-hash list to find candidates.
+ /// Exclusive with --all and a positional path.
+ #[arg(long, conflicts_with_all = ["path", "all"])]
+ pub pending: bool,
+}
+
+/// Execute `perima hash `.
+///
+/// # Errors
+/// Returns [`CoreError::InvalidPath`] when the file does not exist or is not
+/// indexed; propagates [`CoreError`] from DB and hash operations.
+pub(crate) async fn run(
+ container: &AppContainer,
+ _data_dir: &Path,
+ _device: DeviceId,
+ args: &HashArgs,
+) -> Result<(), CoreError> {
+ if let Some(path) = &args.path {
+ run_single(container, path)
+ } else if args.all {
+ run_all(container).await
+ } else if args.pending {
+ run_pending(container).await
+ } else {
+ Err(CoreError::InvalidPath(
+ "perima hash: specify a , --all, or --pending".into(),
+ ))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Single file
+// ---------------------------------------------------------------------------
+
+/// Resolve a path to a [`FileUuid`] by scanning all indexed locations and
+/// suffix-matching against the canonicalized absolute path.
+///
+/// WHY suffix match: same reasoning as `cmd::metadata::find_by_absolute_suffix`
+/// — the scanner stores paths relative to the scan root, so matching on the
+/// suffix of the absolute path is the only portable resolution strategy without
+/// knowing the original scan root.
+fn resolve_file_uuid(container: &AppContainer, path: &Path) -> Result {
+ if !path.exists() {
+ return Err(CoreError::InvalidPath(format!(
+ "does not exist: {}",
+ path.display()
+ )));
+ }
+ if !path.is_file() {
+ return Err(CoreError::InvalidPath(format!(
+ "not a file: {}",
+ path.display()
+ )));
+ }
+
+ let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::from)?;
+ let absolute_str = absolute
+ .to_str()
+ .ok_or_else(|| CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute.display())))?;
+
+ // WHY list across ALL volumes (None): the user supplies an absolute path
+ // that may live on any known volume; suffix-matching across the full
+ // location set is the only portable approach.
+ let records = container.files_repo.list_file_locations(usize::MAX, None)?;
+ let record = find_by_absolute_suffix(&records, absolute_str).ok_or_else(|| {
+ CoreError::InvalidPath(format!(
+ "not indexed: {} (run `perima scan` first)",
+ absolute.display(),
+ ))
+ })?;
+
+ Ok(record.file_uuid)
+}
+
+/// `perima hash ` — compute + print full hash for a single file.
+///
+/// WHY compute directly here instead of via `execute_single`: `execute_single`
+/// looks up the on-disk path through `volume_mounts` so it can work without a
+/// user-supplied path. For the CLI case the user has already given us the path,
+/// so we skip the mount-point reconstruction (which requires a correctly seeded
+/// `volume_mounts` row) and hash the supplied path directly. We then call
+/// `update_full_hash` to persist the result, mirroring what `execute_single`
+/// does internally. This avoids "no such file" errors caused by
+/// `mount_point + relative_path` reconstruction when the scan root ≠ mount.
+fn run_single(container: &AppContainer, path: &Path) -> Result<(), CoreError> {
+ let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::from)?;
+
+ // Get the file size for the dispatch decision.
+ let meta = std::fs::metadata(&absolute).map_err(CoreError::from)?;
+ let size_bytes = meta.len();
+
+ // Resolve the file_uuid so we can persist the result.
+ let file_uuid = resolve_file_uuid(container, path)?;
+
+ // Hash the file directly using the container's hasher.
+ // WHY DeviceKind::Unknown: same rationale as `ComputeFullHashUseCase::compute_one`.
+ let hash = container
+ .hasher
+ .full_hash_dispatched(&absolute, size_bytes, DeviceKind::Unknown)
+ .map_err(|e| perima_core::CoreError::FullHashUnavailable {
+ reason: FullHashUnavailableReason::IoError {
+ message: e.to_string(),
+ },
+ })?;
+
+ // Persist the full hash.
+ container.files_repo.update_full_hash(file_uuid, hash)?;
+
+ println!("{}", hash.to_hex());
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// --all
+// ---------------------------------------------------------------------------
+
+/// `perima hash --all` — iterate every indexed file and compute its full hash.
+///
+/// WHY sequential (not parallel): avoids parallel disk thrash on HDDs.
+/// Spec §4.5 mandates sequential full-hash computation per batch. Power
+/// users who want parallelism can use `compute_full_hash_batch` via the
+/// desktop route or build a shell pipe over `perima hash `.
+async fn run_all(container: &AppContainer) -> Result<(), CoreError> {
+ // WHY list_file_locations(MAX, None): the simplest way to get all
+ // file_uuids without introducing a dedicated "list all uuids" port
+ // method. The result may contain duplicate file_uuids when a file has
+ // multiple active locations; we dedup below.
+ let records = container.files_repo.list_file_locations(usize::MAX, None)?;
+
+ // Dedup: keep unique file_uuids only.
+ let mut seen = HashSet::new();
+ let uuids: Vec = records
+ .into_iter()
+ .filter_map(|r| {
+ if seen.insert(r.file_uuid) {
+ Some(r.file_uuid)
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ let total = uuids.len();
+ if total == 0 {
+ println!("no indexed files found; run `perima scan` first");
+ return Ok(());
+ }
+
+ println!("computing full hash for {total} files (sequential)...");
+
+ let mut ok_count: usize = 0;
+ let mut err_count: usize = 0;
+
+ for (i, file_uuid) in uuids.iter().enumerate() {
+ let n = i + 1;
+ match container.compute_full_hash.execute_single(*file_uuid).await {
+ Ok(hash) => {
+ println!("{n}/{total} {} OK", hash.to_hex());
+ ok_count += 1;
+ }
+ Err(e) => {
+ eprintln!("{n}/{total} (error: {e})");
+ err_count += 1;
+ }
+ }
+ }
+
+ println!("done: {ok_count} computed, {err_count} errors");
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// --pending
+// ---------------------------------------------------------------------------
+
+/// `perima hash --pending` — compute full hash for every file whose
+/// `blake3_hash` is `NULL` (pending full-hash computation).
+///
+/// WHY use `list_files_pending_full_hash`: the backfill worker populates
+/// `quick_hash` for old rows, but `full_hash` / `blake3_hash` is only
+/// computed on demand. This subcommand is the CLI escape hatch for power
+/// users who want to front-load all full-hash computation before the
+/// desktop dedup route surfaces collisions.
+async fn run_pending(container: &AppContainer) -> Result<(), CoreError> {
+ // WHY large limit: we want all pending rows; the DB cursor keeps
+ // memory bounded via the pool connection.
+ let uuids = container
+ .files_repo
+ .list_files_pending_full_hash(usize::MAX)?;
+
+ let total = uuids.len();
+ if total == 0 {
+ println!("no files pending full-hash computation");
+ return Ok(());
+ }
+
+ println!("computing full hash for {total} pending files (sequential)...");
+
+ let mut ok_count: usize = 0;
+ let mut err_count: usize = 0;
+
+ for (i, file_uuid) in uuids.iter().enumerate() {
+ let n = i + 1;
+ match container.compute_full_hash.execute_single(*file_uuid).await {
+ Ok(hash) => {
+ println!("{n}/{total} {} OK", hash.to_hex());
+ ok_count += 1;
+ }
+ Err(e) => {
+ eprintln!("{n}/{total} (error: {e})");
+ err_count += 1;
+ }
+ }
+ }
+
+ println!("done: {ok_count} computed, {err_count} errors");
+ Ok(())
+}
diff --git a/crates/cli/src/cmd/ls.rs b/crates/cli/src/cmd/ls.rs
index 7002def..610f6ff 100644
--- a/crates/cli/src/cmd/ls.rs
+++ b/crates/cli/src/cmd/ls.rs
@@ -140,12 +140,15 @@ fn apply_volume_filter(
}
fn apply_metadata_volume_filter(
- rows: Vec<(FileLocationRecord, Option)>,
+ rows: Vec<(FileLocationRecord, Option, Option)>,
volume: Option,
-) -> Vec<(FileLocationRecord, Option)> {
+) -> Vec<(FileLocationRecord, Option, Option)> {
match volume {
None => rows,
- Some(v) => rows.into_iter().filter(|(r, _)| r.volume_id == v).collect(),
+ Some(v) => rows
+ .into_iter()
+ .filter(|(r, _, _)| r.volume_id == v)
+ .collect(),
}
}
@@ -155,22 +158,25 @@ fn apply_tag_filter(
) -> Vec {
match filter {
None => records,
+ // WHY `r.hash.is_some_and(...)`: post-Task-11 `FileLocationRecord.hash`
+ // is `Option`. Pending files (no full_hash) cannot match a
+ // tag filter keyed on content hash; they're silently excluded.
Some(set) => records
.into_iter()
- .filter(|r| set.contains(&r.hash))
+ .filter(|r| r.hash.is_some_and(|h| set.contains(&h)))
.collect(),
}
}
fn apply_metadata_tag_filter(
- rows: Vec<(FileLocationRecord, Option)>,
+ rows: Vec<(FileLocationRecord, Option, Option)>,
filter: Option<&HashSet>,
-) -> Vec<(FileLocationRecord, Option)> {
+) -> Vec<(FileLocationRecord, Option, Option)> {
match filter {
None => rows,
Some(set) => rows
.into_iter()
- .filter(|(r, _)| set.contains(&r.hash))
+ .filter(|(r, _, _)| r.hash.is_some_and(|h| set.contains(&h)))
.collect(),
}
}
@@ -185,8 +191,11 @@ fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> {
)
.map_err(CoreError::from)?;
for r in records {
- let hash_hex = r.hash.to_hex();
- let hash_short = &hash_hex[..8];
+ // WHY `pending` placeholder when hash is None: post-Task-11 a row may
+ // have no `full_hash` yet (pending dedup). Render an 8-char "pending"
+ // sigil so columns align without forcing the operator to scroll.
+ 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();
let vol_short = &vol_str[..8];
let size = super::format::format_size(r.size.0);
@@ -202,7 +211,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)],
+ rows: &[(FileLocationRecord, Option, Option)],
) -> Result<(), CoreError> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
@@ -212,9 +221,9 @@ fn print_table_with_metadata(
"HASH", "SIZE", "VOLUME", "CAPTURED_AT", "DIMS", "CAMERA",
)
.map_err(CoreError::from)?;
- for (r, meta) in rows {
- let hash_hex = r.hash.to_hex();
- let hash_short = &hash_hex[..8];
+ for (r, meta, _quick_hash) 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();
let vol_short = &vol_str[..8];
let size = super::format::format_size(r.size.0);
diff --git a/crates/cli/src/cmd/metadata.rs b/crates/cli/src/cmd/metadata.rs
index b32a7f6..c0cee2e 100644
--- a/crates/cli/src/cmd/metadata.rs
+++ b/crates/cli/src/cmd/metadata.rs
@@ -98,7 +98,16 @@ pub(crate) async fn run(
absolute_path.display(),
)));
};
- let hash = record.hash;
+ // WHY require Some(hash): `perima metadata ` works on the
+ // content-hash key. Pending files (no full_hash) cannot be re-extracted
+ // until `perima verify` populates the hash. Surface a typed error pointing
+ // the user at the right next step.
+ let hash = record.hash.ok_or_else(|| {
+ CoreError::Unsupported(format!(
+ "file has no full_hash yet: {} (run `perima verify` first)",
+ absolute_path.display(),
+ ))
+ })?;
// Spawn queue + enqueue. A fresh `CancellationToken` is fine here:
// `perima metadata` is interactive and short-lived; Ctrl-C during
@@ -259,11 +268,12 @@ fn print_table(meta: &MediaMetadata, record: &FileLocationRecord) -> Result<(),
#[cfg(test)]
mod tests {
use super::*;
- use perima_core::{BlakeHash, FileSize, LocationStatus, MediaPath, VolumeId};
+ use perima_core::{BlakeHash, FileSize, FileUuid, LocationStatus, MediaPath, VolumeId};
fn rec(p: &str) -> FileLocationRecord {
FileLocationRecord {
- hash: BlakeHash::from_bytes([0u8; 32]),
+ file_uuid: FileUuid::new(),
+ hash: Some(BlakeHash::from_bytes([0u8; 32])),
size: FileSize(0),
volume_id: VolumeId(uuid::Uuid::nil()),
relative_path: MediaPath::new(p),
diff --git a/crates/cli/src/cmd/migrate_data_dir.rs b/crates/cli/src/cmd/migrate_data_dir.rs
new file mode 100644
index 0000000..450d683
--- /dev/null
+++ b/crates/cli/src/cmd/migrate_data_dir.rs
@@ -0,0 +1,272 @@
+//! `perima migrate-data-dir` — one-shot migration of legacy CLI data into the
+//! canonical (desktop-shared) data dir.
+//!
+//! v0.6.x users may have CLI data in `~/.local/share/perima/` (the path that
+//! `directories::ProjectDirs::from("dev","perima","perima")` resolves to on
+//! Linux). After GH #154, both CLI and desktop share the Tauri bundle-id path
+//! `~/.local/share/dev.perima.desktop/perima/`. This command moves the legacy
+//! DB files into the canonical path so users can switch without losing data.
+//!
+//! Refuses to migrate if the canonical path already contains `perima.db`, to
+//! prevent overwriting an active desktop database.
+
+use std::path::{Path, PathBuf};
+
+use clap::Parser;
+use perima_core::CoreError;
+
+/// Arguments for the `migrate-data-dir` subcommand.
+#[derive(Parser, Debug)]
+pub(crate) struct MigrateDataDirArgs {
+ /// Print what would happen without making any changes.
+ #[arg(long)]
+ pub dry_run: bool,
+}
+
+/// Legacy data directory — the path `directories::ProjectDirs::from("dev","perima","perima")`
+/// resolves to on each platform, which is what the CLI used before GH #154.
+///
+/// WHY inline here (not in `resolve_data_dir`): the shared resolver MUST NOT
+/// return the legacy path; it returns the canonical bundle-id path. We keep the
+/// old lookup here only so we can find existing user data to migrate it.
+fn legacy_data_dir() -> Option {
+ directories::ProjectDirs::from("dev", "perima", "perima").map(|d| d.data_dir().to_path_buf())
+}
+
+/// Run the `migrate-data-dir` subcommand (thin shim — resolves platform paths
+/// then delegates to [`migrate`]).
+///
+/// The canonical destination respects the `PERIMA_DATA_DIR` env var so that
+/// tests and CI can redirect the target without touching real user data.
+///
+/// # Errors
+/// Returns `CoreError::Internal` if the canonical path cannot be resolved, or
+/// `CoreError::Io` on filesystem failures (dir creation, rename).
+pub(crate) fn run(args: &MigrateDataDirArgs) -> Result<(), CoreError> {
+ let Some(legacy) = legacy_data_dir() else {
+ println!("migrate-data-dir: cannot resolve legacy path; nothing to migrate.");
+ return Ok(());
+ };
+ // WHY honour PERIMA_DATA_DIR: mirrors the precedence in Config::resolve,
+ // allowing tests + CI to redirect the canonical path without touching the
+ // user's real DB. When PERIMA_DATA_DIR is set the migration target is the
+ // override; otherwise it is the shared bundle-id path from resolve_data_dir.
+ let canonical = std::env::var_os("PERIMA_DATA_DIR")
+ .map(PathBuf::from)
+ .map_or_else(perima_app::config::resolve_data_dir, Ok)?;
+ migrate(&legacy, &canonical, args.dry_run)
+}
+
+/// Core migration logic accepting explicit paths.
+///
+/// WHY explicit-path signature: enables unit tests with `TempDir`-backed paths
+/// without mutating env vars (which are `unsafe` since Rust 1.86 and banned
+/// workspace-wide by `#![forbid(unsafe_code)]`).
+///
+/// # Errors
+/// Returns `CoreError::Internal` when the canonical DB already exists (refuses
+/// to overwrite), or `CoreError::Io` on filesystem failures.
+#[allow(clippy::module_name_repetitions)] // WHY: `migrate_data_dir::migrate` is the natural name; lint is overzealous here.
+pub(crate) fn migrate(legacy: &Path, canonical: &Path, dry_run: bool) -> Result<(), CoreError> {
+ // If legacy and canonical are the same directory (shouldn't happen post-#154
+ // but handle it gracefully), there is nothing to do.
+ if legacy == canonical {
+ println!(
+ "migrate-data-dir: legacy and canonical paths are identical — nothing to migrate."
+ );
+ println!(" path: {}", legacy.display());
+ return Ok(());
+ }
+
+ let legacy_db = legacy.join("perima.db");
+
+ // No legacy DB → nothing to move.
+ if !legacy_db.exists() {
+ println!(
+ "migrate-data-dir: no legacy database found at {}; nothing to migrate.",
+ legacy.display()
+ );
+ return Ok(());
+ }
+
+ let canonical_db = canonical.join("perima.db");
+
+ // Canonical DB already present → refuse to overwrite.
+ if canonical_db.exists() {
+ eprintln!(
+ "migrate-data-dir: canonical database already exists at {}.",
+ canonical_db.display()
+ );
+ eprintln!(" Refusing to overwrite. Manually back up or remove the existing DB first.");
+ return Err(CoreError::Internal(
+ "canonical database already exists; refusing to overwrite".into(),
+ ));
+ }
+
+ println!(
+ "migrate-data-dir: found legacy database at {}",
+ legacy.display()
+ );
+ println!(" → canonical path: {}", canonical.display());
+
+ // Enumerate files to migrate (db + WAL side-cars).
+ let files_to_move: Vec<(&str, PathBuf, PathBuf)> =
+ ["perima.db", "perima.db-shm", "perima.db-wal"]
+ .iter()
+ .filter_map(|name| {
+ let src = legacy.join(name);
+ if src.exists() {
+ let dst = canonical.join(name);
+ Some((*name, src, dst))
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ for (name, src, dst) in &files_to_move {
+ if dry_run {
+ println!(
+ " [dry-run] would move {} → {}",
+ src.display(),
+ dst.display()
+ );
+ } else {
+ println!(" moving {name} ...");
+ }
+ }
+
+ if dry_run {
+ println!("migrate-data-dir: dry-run complete; no files were moved.");
+ return Ok(());
+ }
+
+ // Create the canonical directory tree if it doesn't exist yet.
+ std::fs::create_dir_all(canonical)?;
+
+ for (_name, src, dst) in &files_to_move {
+ // WHY copy+remove fallback: `fs::rename` is atomic and preferred but
+ // fails with `CrossesDevices` (EXDEV / os error 18) when `legacy` and
+ // `canonical` live on different filesystems (e.g. home on ext4 vs
+ // tmp on tmpfs in tests). The fallback is not atomic but safe for
+ // migration: if interrupted, the source file stays intact.
+ if let Err(e) = std::fs::rename(src, dst) {
+ if e.kind() == std::io::ErrorKind::CrossesDevices {
+ std::fs::copy(src, dst)?;
+ std::fs::remove_file(src)?;
+ } else {
+ return Err(CoreError::from(e));
+ }
+ }
+ }
+
+ println!(
+ "migrate-data-dir: migration complete. {} file(s) moved to {}.",
+ files_to_move.len(),
+ canonical.display()
+ );
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use super::*;
+
+ /// No legacy DB present → returns Ok, no files created in canonical dir.
+ #[test]
+ fn migrate_nothing_when_legacy_db_absent() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let legacy = tmp.path().join("legacy");
+ let canonical = tmp.path().join("canonical");
+ fs::create_dir_all(&legacy).expect("create legacy dir");
+
+ migrate(&legacy, &canonical, false).expect("should succeed");
+
+ // Canonical dir was NOT created when there was nothing to migrate.
+ assert!(
+ !canonical.join("perima.db").exists(),
+ "canonical perima.db should not exist"
+ );
+ }
+
+ /// Legacy DB present, canonical dir empty → files are moved.
+ #[test]
+ fn migrate_moves_db_when_legacy_has_data() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let legacy = tmp.path().join("legacy");
+ let canonical = tmp.path().join("canonical");
+ fs::create_dir_all(&legacy).expect("create legacy dir");
+
+ // Create a fake legacy DB + WAL.
+ fs::write(legacy.join("perima.db"), b"fake-db").expect("write db");
+ fs::write(legacy.join("perima.db-wal"), b"fake-wal").expect("write wal");
+
+ migrate(&legacy, &canonical, false).expect("migrate");
+
+ assert!(
+ canonical.join("perima.db").exists(),
+ "canonical perima.db should exist after migration"
+ );
+ assert!(
+ canonical.join("perima.db-wal").exists(),
+ "canonical perima.db-wal should exist after migration"
+ );
+ assert!(
+ !legacy.join("perima.db").exists(),
+ "legacy perima.db should be gone after migration"
+ );
+ }
+
+ /// Dry run: files remain in legacy dir, canonical dir untouched.
+ #[test]
+ fn migrate_dry_run_does_not_move_files() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let legacy = tmp.path().join("legacy");
+ let canonical = tmp.path().join("canonical");
+ fs::create_dir_all(&legacy).expect("create legacy dir");
+ fs::write(legacy.join("perima.db"), b"fake-db").expect("write db");
+
+ migrate(&legacy, &canonical, true).expect("dry-run");
+
+ assert!(
+ legacy.join("perima.db").exists(),
+ "legacy db should be untouched after dry-run"
+ );
+ assert!(
+ !canonical.join("perima.db").exists(),
+ "canonical db should not exist after dry-run"
+ );
+ }
+
+ /// Canonical DB already present → returns Err to prevent overwrite.
+ #[test]
+ fn migrate_refuses_when_canonical_db_exists() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let legacy = tmp.path().join("legacy");
+ let canonical = tmp.path().join("canonical");
+ fs::create_dir_all(&legacy).expect("create legacy");
+ fs::create_dir_all(&canonical).expect("create canonical");
+ fs::write(legacy.join("perima.db"), b"old-db").expect("write legacy db");
+ fs::write(canonical.join("perima.db"), b"new-db").expect("write canonical db");
+
+ let result = migrate(&legacy, &canonical, false);
+ assert!(
+ result.is_err(),
+ "should refuse to overwrite existing canonical DB"
+ );
+ }
+
+ /// Same legacy and canonical path → reports nothing to do.
+ #[test]
+ fn migrate_same_paths_is_noop() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let path = tmp.path().join("data");
+ fs::create_dir_all(&path).expect("create dir");
+ fs::write(path.join("perima.db"), b"db").expect("write db");
+
+ // Same path for both — should succeed without error.
+ migrate(&path, &path, false).expect("same-path should be noop");
+ }
+}
diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs
index 72dc546..8970de2 100644
--- a/crates/cli/src/cmd/mod.rs
+++ b/crates/cli/src/cmd/mod.rs
@@ -1,9 +1,12 @@
//! CLI subcommand modules.
pub(crate) mod debug_report;
+pub(crate) mod dedup;
pub(crate) mod format;
+pub(crate) mod hash;
pub(crate) mod ls;
pub(crate) mod metadata;
+pub(crate) mod migrate_data_dir;
pub(crate) mod scan;
pub(crate) mod search;
pub(crate) mod tag;
diff --git a/crates/cli/src/cmd/search.rs b/crates/cli/src/cmd/search.rs
index 8d40bc4..1657a36 100644
--- a/crates/cli/src/cmd/search.rs
+++ b/crates/cli/src/cmd/search.rs
@@ -74,15 +74,17 @@ pub(crate) async fn run(container: &AppContainer, args: &SearchArgs) -> Result<(
}
// Plain table output: HASH(8) | PATH | RANK
+ // WHY map+default: post-Task-11 `SearchHit.blake3_hash` is `Option`
+ // because pending files (no full_hash) can still hit the FTS index.
+ // Render an 8-char "pending" sigil so columns align.
println!("{:<8} {:<60} RANK", "HASH", "PATH");
println!("{}", "-".repeat(80));
for h in &hits {
- println!(
- "{:<8} {:<60} {:.4}",
- &h.blake3_hash[..8.min(h.blake3_hash.len())],
- h.relative_path,
- h.rank
- );
+ let hash_short: String = h
+ .blake3_hash
+ .as_deref()
+ .map_or_else(|| "pending ".to_owned(), |s| s[..8.min(s.len())].to_owned());
+ println!("{:<8} {:<60} {:.4}", hash_short, h.relative_path, h.rank);
}
Ok(())
}
diff --git a/crates/cli/src/cmd/tag.rs b/crates/cli/src/cmd/tag.rs
index 0393c29..0d5dcfe 100644
--- a/crates/cli/src/cmd/tag.rs
+++ b/crates/cli/src/cmd/tag.rs
@@ -102,7 +102,16 @@ fn resolve_hash(container: &AppContainer, path: &Path) -> Result {
+ FileEvent::Modified { path, volume, .. } => {
let n = self.repo.update_location_status(
*volume,
path,
@@ -85,7 +85,7 @@ impl DbEventHandler {
);
Ok(())
}
- FileEvent::Deleted { path, volume } => {
+ FileEvent::Deleted { path, volume, .. } => {
let n = self.repo.update_location_status(
*volume,
path,
@@ -99,7 +99,9 @@ impl DbEventHandler {
);
Ok(())
}
- FileEvent::Renamed { from, to, volume } => {
+ FileEvent::Renamed {
+ from, to, volume, ..
+ } => {
let n = self
.repo
.update_location_path(*volume, from, to, self.device)?;
diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs
index f6ccee5..cb40e88 100644
--- a/crates/cli/src/config.rs
+++ b/crates/cli/src/config.rs
@@ -29,22 +29,42 @@ pub(crate) struct Config {
}
impl Config {
- /// Resolve from the `directories` crate, then env overrides
- /// (`PERIMA_DATA_DIR`, `PERIMA_CONFIG_DIR`), then CLI overrides.
+ /// Resolve from the shared [`perima_app::config::resolve_data_dir`] (which
+ /// matches Tauri's bundle-id path), then env overrides (`PERIMA_DATA_DIR`,
+ /// `PERIMA_CONFIG_DIR`), then CLI `--data-dir` override.
/// Creates `/device_id.txt` on first run.
///
+ /// WHY use `resolve_data_dir` from `perima-app`: both CLI and desktop must
+ /// read/write the same `SQLite` database. GH #154 — before this change, CLI
+ /// used `directories::ProjectDirs::from("dev","perima","perima")` →
+ /// `~/.local/share/perima/`, while desktop used the Tauri bundle-id path
+ /// `~/.local/share/dev.perima.desktop/perima/`. Tags added via one shell
+ /// were invisible to the other. The shared resolver locks both shells to
+ /// the Tauri bundle-id path so they converge on the same DB file.
+ ///
+ /// The `--data-dir` CLI flag and the `PERIMA_DATA_DIR` env var still take
+ /// precedence (for testing, CI, and custom-path installs).
+ ///
/// # Errors
/// Returns `CoreError::Internal` if platform directories cannot
/// be resolved, or `CoreError::Io` on filesystem failures.
pub(crate) fn resolve(cli_data_dir: Option) -> Result {
+ // WHY config_dir still uses ProjectDirs: the config dir (device_id.txt)
+ // predates #154 and is machine-local only — it does not need to match
+ // the desktop path. The data_dir (the DB) is what must match.
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_or_else(|| dirs.config_dir().to_path_buf(), PathBuf::from);
+
+ // WHY `resolve_data_dir()` as default: aligns CLI with the desktop
+ // bundle-id path. `PERIMA_DATA_DIR` env override + `--data-dir` flag
+ // still take precedence for tests and custom installs.
+ let canonical_data_dir = perima_app::config::resolve_data_dir()?;
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());
+ .unwrap_or(canonical_data_dir);
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&data_dir)?;
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index 6d7b996..87da534 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -21,14 +21,17 @@ use std::process::ExitCode;
use std::sync::Arc;
use clap::{Parser, Subcommand};
-use perima_app::{AppContainer, AppDeps, EventHandler};
+use perima_app::{
+ AppContainer, AppDeps, BackfillRate, EventHandler, QuickHashBackfillWorker,
+ backfill::{BACKFILL_QUERY_LIMIT, choose_backfill_rate},
+};
use perima_core::{
- FileRepository, HashService, MetadataRepository, Scanner, SearchRepository, TagRepository,
- VolumeRepository,
+ FileRepository, HashService, IdentityCacheRepository, MetadataRepository, Scanner,
+ SearchRepository, TagRepository, VolumeRepository,
};
use perima_db::{
- ReadPool, SqliteFileRepository, SqliteMetadataRepository, SqliteSearchRepository,
- SqliteTagRepository, SqliteVolumeRepository, SqliteWriter,
+ ReadPool, SqliteFileRepository, SqliteIdentityCacheRepository, SqliteMetadataRepository,
+ SqliteSearchRepository, SqliteTagRepository, SqliteVolumeRepository, SqliteWriter,
};
use perima_fs::WalkdirScanner;
use perima_hash::Blake3Service;
@@ -107,6 +110,19 @@ enum Command {
/// Tag management: add, remove, and list tags.
Tag(cmd::tag::TagArgs),
+ /// Compute the full BLAKE3 hash for one or more indexed files.
+ ///
+ /// Use `perima hash ` for a single file, `--all` to hash every
+ /// indexed file, or `--pending` to hash only files whose full hash has
+ /// not yet been computed.
+ Hash(cmd::hash::HashArgs),
+
+ /// Inspect and resolve quick-hash duplicate candidates.
+ ///
+ /// Use `--check` to list groups, `--verify` to compute full hashes on
+ /// candidates, or `mark-distinct ...` to memorise false positives.
+ Dedup(cmd::dedup::DedupArgs),
+
/// Full-text search over indexed file metadata and tags.
Search(cmd::search::SearchArgs),
@@ -138,6 +154,11 @@ enum Command {
#[arg(long, default_value_t = 2)]
include_rotated: usize,
},
+
+ /// Move legacy CLI data (`~/.local/share/perima/`) to the canonical
+ /// 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),
}
/// Entry point.
@@ -211,6 +232,10 @@ async fn main() -> ExitCode {
Command::Tag(args) => dispatch_tag(&args, &config).await,
+ Command::Hash(args) => dispatch_hash(&args, &config).await,
+
+ Command::Dedup(args) => dispatch_dedup(&args, &config).await,
+
Command::Search(args) => dispatch_search(&args, &config).await,
Command::Volumes => dispatch_volumes(&config).await,
@@ -223,6 +248,8 @@ async fn main() -> ExitCode {
path,
include_rotated,
} => dispatch_debug_report(path, include_rotated),
+
+ Command::MigrateDataDir(args) => dispatch_migrate_data_dir(&args),
}
}
@@ -242,6 +269,20 @@ fn dispatch_debug_report(path: Option, include_rotated: usize) -> ExitC
}
}
+/// Run the `migrate-data-dir` subcommand.
+///
+/// WHY sync (no `async`): migration is pure filesystem rename — no DB or
+/// async port involved.
+fn dispatch_migrate_data_dir(args: &cmd::migrate_data_dir::MigrateDataDirArgs) -> ExitCode {
+ match cmd::migrate_data_dir::run(args) {
+ Ok(()) => ExitCode::from(0),
+ Err(e) => {
+ eprintln!("perima: {e}");
+ ExitCode::from(1)
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// AppContainer construction
// ---------------------------------------------------------------------------
@@ -287,7 +328,9 @@ fn build_container(
reads.clone(),
));
let search: Arc =
- Arc::new(SqliteSearchRepository::new(writer.sender(), reads));
+ Arc::new(SqliteSearchRepository::new(writer.sender(), reads.clone()));
+ let identity_cache: Arc =
+ Arc::new(SqliteIdentityCacheRepository::new(writer.sender(), reads));
let hasher: Arc = Arc::new(Blake3Service::new());
let scanner: Arc = Arc::new(WalkdirScanner::new());
// WHY no explicit `writer` keep-alive: every adapter above holds a
@@ -316,6 +359,7 @@ fn build_container(
tags,
metadata,
search,
+ identity_cache,
hasher,
scanner,
thumbnailer,
@@ -467,9 +511,87 @@ async fn dispatch_scan(
) as perima_app::OnPersist)
};
+ // Spawn the quick_hash backfill worker in the background (spec §4.1.5).
+ // WHY here (not in main): this is the primary command that reads the DB
+ // regularly; spawning here ensures the backfill runs alongside the scan
+ // without blocking the user's foreground operation.
+ spawn_backfill(&container, config.device_id, cancel.token());
+
map_scan_result(cmd::scan::run(&container, config.device_id, cancel, on_persist, &args).await)
}
+/// Spawn the `quick_hash` backfill worker in the background.
+///
+/// Queries the database for `files.quick_hash IS NULL` rows via
+/// [`FileRepository::list_files_needing_backfill`], selects an appropriate
+/// rate (spec §4.1.5: more than 10k rows → 50/s, otherwise unlimited), and
+/// `tokio::spawn`s the worker. The `JoinHandle` is intentionally dropped —
+/// the task runs to completion independently.
+///
+/// WHY `device_id` arg: `upsert_file_with_quick_hash` needs the device id for
+/// the `updated_at + device_id` per-row CRDT columns (CLAUDE.md schema rules).
+fn spawn_backfill(
+ container: &AppContainer,
+ device_id: perima_core::DeviceId,
+ cancel: tokio_util::sync::CancellationToken,
+) {
+ let rows = match container
+ .files_repo
+ .list_files_needing_backfill(BACKFILL_QUERY_LIMIT)
+ {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!(error = %e, "backfill: could not query null quick_hash rows");
+ return;
+ }
+ };
+
+ if rows.is_empty() {
+ return; // Nothing to backfill — common path on healthy installs.
+ }
+
+ let null_count = rows.len() as u64;
+ let rate = choose_backfill_rate(null_count);
+ tracing::info!(
+ null_count,
+ rate = ?rate,
+ "spawning quick_hash backfill worker"
+ );
+
+ // Convert BackfillFileRow (core type) to BackfillRow (app type).
+ let backfill_rows: Vec<_> = rows
+ .into_iter()
+ .filter_map(|r| {
+ r.active_path.map(|path| perima_app::BackfillRow {
+ hash: r.hash,
+ size_bytes: r.size_bytes,
+ path,
+ })
+ })
+ .collect();
+
+ let null_count_after_filter = backfill_rows.len() as u64;
+ let rate: BackfillRate = if null_count_after_filter == null_count {
+ rate
+ } else {
+ // Some rows had no active path; re-evaluate rate with actual count.
+ choose_backfill_rate(null_count_after_filter)
+ };
+
+ let _handle = QuickHashBackfillWorker::spawn(
+ Box::new(backfill_rows.into_iter()),
+ Arc::clone(&container.hasher),
+ Arc::clone(&container.files_repo),
+ device_id,
+ rate,
+ Arc::clone(&container.events),
+ cancel,
+ );
+ // WHY handle dropped: task runs independently; process exits naturally
+ // when the CLI command completes, which cancels the token automatically
+ // (caller holds the Cancellation which wraps the token).
+}
+
/// Convert a scan result to a process `ExitCode`.
fn map_scan_result(
res: Result<(cmd::scan::ExitCode, cmd::scan::ScanStats), perima_core::CoreError>,
@@ -558,6 +680,48 @@ 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![]) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("perima: database: {e}");
+ return ExitCode::from(1);
+ }
+ };
+ match cmd::hash::run(&container, &config.data_dir, config.device_id, args).await {
+ Ok(()) => ExitCode::from(0),
+ Err(perima_core::CoreError::InvalidPath(msg)) => {
+ eprintln!("perima: {msg}");
+ ExitCode::from(2)
+ }
+ Err(e) => {
+ eprintln!("perima: {e}");
+ ExitCode::from(1)
+ }
+ }
+}
+
+/// 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![]) {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("perima: database: {e}");
+ return ExitCode::from(1);
+ }
+ };
+ match cmd::dedup::run(&container, &config.data_dir, config.device_id, args).await {
+ Ok(()) => ExitCode::from(0),
+ Err(e) => {
+ eprintln!("perima: {e}");
+ ExitCode::from(1)
+ }
+ }
+}
+
/// Run the `watch` subcommand.
async fn dispatch_watch(root: PathBuf, config: &Config, cancel: &Cancellation) -> ExitCode {
let db_path = config.data_dir.join("perima.db");
diff --git a/crates/cli/tests/dedup_subcommand_test.rs b/crates/cli/tests/dedup_subcommand_test.rs
new file mode 100644
index 0000000..9f5ca10
--- /dev/null
+++ b/crates/cli/tests/dedup_subcommand_test.rs
@@ -0,0 +1,173 @@
+//! Integration tests: `perima dedup` subcommand.
+//!
+//! WHY subprocess-based: verifies the full dispatch path including DB schema
+//! migrations (V011 `quick_hash` + `verified_distinct` columns) and clap argument
+//! parsing through the real binary entry point.
+
+use std::io::Write;
+use std::path::Path;
+use std::process::Command;
+
+const fn bin() -> &'static str {
+ env!("CARGO_BIN_EXE_perima")
+}
+
+fn run_scan(td: &Path, env_dir: &Path) {
+ let out = Command::new(bin())
+ .args(["scan", "--no-thumbnails"])
+ .arg(td)
+ .env("PERIMA_CONFIG_DIR", env_dir)
+ .env("PERIMA_DATA_DIR", env_dir)
+ .output()
+ .expect("spawn perima scan");
+ assert!(
+ out.status.success(),
+ "scan failed\nstderr: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
+
+/// `perima dedup --check` on an empty database exits 0 and reports no
+/// candidate groups.
+#[test]
+fn dedup_check_empty_db_prints_no_candidates() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+
+ // Create + scan a single unique file.
+ let file1 = td.path().join("unique.txt");
+ std::fs::File::create(&file1)
+ .expect("create file")
+ .write_all(b"this content is unique and produces no collision")
+ .expect("write");
+
+ run_scan(td.path(), env_dir.path());
+
+ let out = Command::new(bin())
+ .args(["dedup", "check"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima dedup check");
+
+ assert!(
+ out.status.success(),
+ "dedup check must exit 0\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let stdout = String::from_utf8(out.stdout).expect("utf8");
+ assert!(
+ stdout.contains("no duplicate") || stdout.contains("0 candidate"),
+ "expected 'no duplicate' or '0 candidate' in: {stdout}"
+ );
+}
+
+/// `perima dedup check` exits 0 when there are no indexed files at all.
+#[test]
+fn dedup_check_no_indexed_files_exits_zero() {
+ // Don't scan any files — empty DB.
+ let env_dir = tempfile::tempdir().expect("env dir");
+
+ let out = Command::new(bin())
+ .args(["dedup", "check"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima dedup check");
+
+ assert!(
+ out.status.success(),
+ "dedup check on empty db must exit 0\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
+
+/// `perima dedup verify` exits 0 when there are no candidates.
+#[test]
+fn dedup_verify_no_candidates_exits_zero() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+
+ let file1 = td.path().join("solo.txt");
+ std::fs::File::create(&file1)
+ .expect("create")
+ .write_all(b"completely unique content XYZ")
+ .expect("write");
+
+ run_scan(td.path(), env_dir.path());
+
+ let out = Command::new(bin())
+ .args(["dedup", "verify"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima dedup verify");
+
+ assert!(
+ out.status.success(),
+ "dedup verify must exit 0 with no candidates\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
+
+/// `perima dedup mark-distinct` with a valid UUID exits 0.
+///
+/// WHY use a random UUID: `mark_verified_distinct` in the port is a write
+/// that the default adapter accepts even for non-existent UUIDs (the SQL
+/// UPDATE rows-affected = 0 is not an error). This tests the happy-path
+/// plumbing from CLI → dispatcher → `DedupUseCase` → port.
+#[test]
+fn dedup_mark_distinct_valid_uuid_exits_zero() {
+ let env_dir = tempfile::tempdir().expect("env dir");
+
+ // Need a minimal DB to exist — scan an empty dir to trigger migrations.
+ let scan_dir = tempfile::tempdir().expect("scan dir");
+ run_scan(scan_dir.path(), env_dir.path());
+
+ // Use a valid UUID v4 (not v7 — either is accepted by the parser).
+ let test_uuid = uuid::Uuid::new_v4().to_string();
+
+ let out = Command::new(bin())
+ .args(["dedup", "mark-distinct", &test_uuid])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima dedup mark-distinct");
+
+ assert!(
+ out.status.success(),
+ "dedup mark-distinct with valid UUID must exit 0\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let stdout = String::from_utf8(out.stdout).expect("utf8");
+ assert!(
+ stdout.contains("marked 1 file"),
+ "must confirm 1 file marked, got: {stdout}"
+ );
+}
+
+/// `perima dedup mark-distinct` with a bad UUID string exits non-zero.
+#[test]
+fn dedup_mark_distinct_bad_uuid_exits_nonzero() {
+ let env_dir = tempfile::tempdir().expect("env dir");
+ let scan_dir = tempfile::tempdir().expect("scan dir");
+ run_scan(scan_dir.path(), env_dir.path());
+
+ let out = Command::new(bin())
+ .args(["dedup", "mark-distinct", "not-a-valid-uuid"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima dedup mark-distinct bad");
+
+ assert!(
+ !out.status.success(),
+ "dedup mark-distinct with bad UUID must exit non-zero"
+ );
+}
diff --git a/crates/cli/tests/hash_subcommand_test.rs b/crates/cli/tests/hash_subcommand_test.rs
new file mode 100644
index 0000000..2e1526e
--- /dev/null
+++ b/crates/cli/tests/hash_subcommand_test.rs
@@ -0,0 +1,204 @@
+//! Integration tests: `perima hash` subcommand.
+//!
+//! WHY subprocess-based: verifies the full dispatch path including DB schema
+//! migrations (V011 `file_uuid` + `quick_hash` columns) and clap argument parsing.
+
+use std::io::Write;
+use std::path::Path;
+use std::process::Command;
+
+const fn bin() -> &'static str {
+ env!("CARGO_BIN_EXE_perima")
+}
+
+/// Create two plain-text fixture files in `dir`.
+fn mk_fixture(dir: &Path) {
+ for (name, content) in [
+ ("file1.txt", b"hello world hash test" as &[u8]),
+ ("file2.txt", b"another file for hashing"),
+ ] {
+ let path = dir.join(name);
+ std::fs::File::create(&path)
+ .expect("create fixture")
+ .write_all(content)
+ .expect("write fixture");
+ }
+}
+
+fn run_scan(td: &Path, env_dir: &Path) {
+ let out = Command::new(bin())
+ .args(["scan", "--no-thumbnails"])
+ .arg(td)
+ .env("PERIMA_CONFIG_DIR", env_dir)
+ .env("PERIMA_DATA_DIR", env_dir)
+ .output()
+ .expect("spawn perima scan");
+ assert!(
+ out.status.success(),
+ "scan failed\nstderr: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
+
+/// `perima hash ` on a known indexed file emits a 64-char hex hash.
+#[test]
+fn hash_single_file_emits_hex_hash() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+ mk_fixture(td.path());
+
+ // Index the file first.
+ run_scan(td.path(), env_dir.path());
+
+ let file1 = td.path().join("file1.txt");
+ let out = Command::new(bin())
+ .arg("hash")
+ .arg(&file1)
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima hash ");
+
+ assert!(
+ out.status.success(),
+ "perima hash must exit 0\nstderr: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let stdout = String::from_utf8(out.stdout).expect("utf8");
+ let hash_hex = stdout.trim();
+ // BLAKE3 hex is exactly 64 lowercase hex characters.
+ assert_eq!(
+ hash_hex.len(),
+ 64,
+ "full hash output must be 64 hex chars, got: {hash_hex:?}"
+ );
+ assert!(
+ hash_hex.chars().all(|c| c.is_ascii_hexdigit()),
+ "full hash output must be hex, got: {hash_hex:?}"
+ );
+}
+
+/// `perima hash ` is idempotent — calling it twice on the same file
+/// produces the same hash.
+#[test]
+fn hash_single_file_is_idempotent() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+ mk_fixture(td.path());
+ run_scan(td.path(), env_dir.path());
+
+ let file1 = td.path().join("file1.txt");
+
+ let hash1 = {
+ let out = Command::new(bin())
+ .arg("hash")
+ .arg(&file1)
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn hash 1");
+ assert!(out.status.success(), "first hash failed");
+ String::from_utf8(out.stdout)
+ .expect("utf8")
+ .trim()
+ .to_owned()
+ };
+
+ let hash2 = {
+ let out = Command::new(bin())
+ .arg("hash")
+ .arg(&file1)
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn hash 2");
+ assert!(out.status.success(), "second hash failed");
+ String::from_utf8(out.stdout)
+ .expect("utf8")
+ .trim()
+ .to_owned()
+ };
+
+ assert_eq!(hash1, hash2, "hash must be deterministic across calls");
+}
+
+/// `perima hash --all` on a database with indexed files exits 0 and prints
+/// progress lines.
+#[test]
+fn hash_all_exits_zero_and_prints_progress() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+ mk_fixture(td.path());
+ run_scan(td.path(), env_dir.path());
+
+ let out = Command::new(bin())
+ .args(["hash", "--all"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima hash --all");
+
+ assert!(
+ out.status.success(),
+ "perima hash --all must exit 0\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let stdout = String::from_utf8(out.stdout).expect("utf8");
+ // Should mention "computing" and "done".
+ assert!(
+ stdout.contains("computing") || stdout.contains("no indexed files"),
+ "unexpected output: {stdout}"
+ );
+}
+
+/// `perima hash --pending` exits 0 when there are no pending rows.
+///
+/// WHY "no pending files" case: after a normal scan with no `full_hash`
+/// computation deferral, every file row has its `blake3_hash` set (since
+/// the scan itself sets it), so `--pending` finds nothing to do.
+#[test]
+fn hash_pending_exits_zero_with_no_pending_rows() {
+ let td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+ mk_fixture(td.path());
+ run_scan(td.path(), env_dir.path());
+
+ let out = Command::new(bin())
+ .args(["hash", "--pending"])
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima hash --pending");
+
+ assert!(
+ out.status.success(),
+ "perima hash --pending must exit 0\nstdout: {}\nstderr: {}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
+
+/// `perima hash` without arguments exits non-zero and prints a helpful message.
+#[test]
+fn hash_no_args_exits_nonzero() {
+ let _td = tempfile::tempdir().expect("tempdir");
+ let env_dir = tempfile::tempdir().expect("env dir");
+
+ let out = Command::new(bin())
+ .arg("hash")
+ .env("PERIMA_CONFIG_DIR", env_dir.path())
+ .env("PERIMA_DATA_DIR", env_dir.path())
+ .output()
+ .expect("spawn perima hash");
+
+ // Clap exits 2 for usage errors when neither path nor flag is given.
+ // The run function itself returns an InvalidPath error (exit 2) or
+ // clap catches it first.
+ assert!(
+ !out.status.success(),
+ "perima hash with no args must exit non-zero"
+ );
+}
diff --git a/crates/cli/tests/migrate_data_dir_test.rs b/crates/cli/tests/migrate_data_dir_test.rs
new file mode 100644
index 0000000..32ba5d1
--- /dev/null
+++ b/crates/cli/tests/migrate_data_dir_test.rs
@@ -0,0 +1,38 @@
+//! Integration tests for the `perima migrate-data-dir` command surface.
+//!
+//! Full path-logic coverage lives in unit tests inside
+//! `crates/cli/src/cmd/migrate_data_dir.rs` (the `migrate()` helper accepts
+//! explicit paths so it can use `TempDir`-backed fixtures without touching
+//! real user data). These process-level tests verify the command wiring and
+//! CLI surface only.
+
+#![allow(clippy::unwrap_used)] // WHY: integration test; panics are assertion failures, not prod bugs.
+
+fn perima_bin() -> std::process::Command {
+ std::process::Command::new(env!("CARGO_BIN_EXE_perima"))
+}
+
+/// `perima migrate-data-dir --help` exits 0 (command is registered).
+#[test]
+fn migrate_data_dir_help_exits_zero() {
+ let status = perima_bin()
+ .args(["migrate-data-dir", "--help"])
+ .status()
+ .expect("run perima migrate-data-dir --help");
+ assert!(status.success(), "migrate-data-dir --help should exit 0");
+}
+
+/// `perima migrate-data-dir --dry-run` with `PERIMA_DATA_DIR` pointing to an
+/// empty canonical dir exits 0, even if the legacy path has no DB.
+#[test]
+fn migrate_data_dir_dry_run_always_exits_zero() {
+ let tmp = tempfile::TempDir::new().expect("tempdir");
+ // Point canonical at the tmp dir. Legacy DB almost certainly absent in CI.
+ // If it is present on a dev machine, dry-run still exits 0.
+ let status = perima_bin()
+ .args(["migrate-data-dir", "--dry-run"])
+ .env("PERIMA_DATA_DIR", tmp.path())
+ .status()
+ .expect("run perima migrate-data-dir --dry-run");
+ assert!(status.success(), "migrate-data-dir --dry-run should exit 0");
+}
diff --git a/crates/core/src/dedup.rs b/crates/core/src/dedup.rs
new file mode 100644
index 0000000..e7bceb2
--- /dev/null
+++ b/crates/core/src/dedup.rs
@@ -0,0 +1,132 @@
+//! Dedup-related core types — exposed to shells via specta-typed bindings.
+//!
+//! See spec §4.7.1.
+
+use serde::{Deserialize, Serialize};
+
+use crate::{BlakeHash, CoreError, FileLocationRecord, FileUuid};
+
+// WHY CoreError is Serialize-only: std::io::Error is !Clone and !Deserialize;
+// CoreError::Io lowers the io::Error at construction and is itself !Deserialize
+// (only derives Serialize). FullHashOutcome::Failed holds a CoreError, so
+// FullHashOutcome cannot derive Deserialize either. It is an outbound-only event
+// payload (frontend never sends it back) so Serialize alone is the correct bound.
+
+/// Stable identifier for a `compute_full_hash_batch` operation.
+///
+/// UUIDv7-derived so batch IDs sort chronologically and do not collide
+/// across devices.
+// WHY specta(transparent): inner field is `uuid::Uuid`; specta maps
+// `Uuid` → `string` via the "uuid" feature so TS sees `string`, not `{ 0: string }`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+#[cfg_attr(feature = "specta", specta(transparent))]
+pub struct BatchId(pub uuid::Uuid);
+
+impl BatchId {
+ /// Generate a fresh `UUIDv7` batch id.
+ #[must_use]
+ pub fn new() -> Self {
+ Self(uuid::Uuid::now_v7())
+ }
+}
+
+impl Default for BatchId {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Returned from `compute_full_hash_batch` — used by the frontend to
+/// subscribe to per-file progress events.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+pub struct BatchHandle {
+ /// Stable id for this batch run.
+ pub batch_id: BatchId,
+ /// Total number of files queued for full-hash computation.
+ pub total: u32,
+}
+
+/// What kind of physical storage backs a volume.
+///
+/// WHY perima-owned (NOT `sysinfo::DiskKind`): `crates/core` has zero
+/// framework dependencies. Adapters convert `sysinfo::DiskKind` → this
+/// enum at the volume adapter boundary, keeping the domain type stable
+/// even if `sysinfo` is swapped out.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+pub enum DeviceKind {
+ /// Spinning rust (HDD).
+ Hdd,
+ /// SATA / `NVMe` / SD / generic non-rotational.
+ Ssd,
+ /// Could not be determined; treat conservatively (defaults to SSD path
+ /// per spec §4.5.3 — more common in 2026 hardware; HDD penalty for
+ /// a falsely-Unknown SSD is worse than the reverse).
+ Unknown,
+}
+
+/// A group of files whose `quick_hash` matches — candidate duplicates.
+///
+/// `verified_state` tracks whether the group has been confirmed by
+/// `full_hash` comparison (see [`VerifiedState`]).
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+pub struct CollisionGroup {
+ /// The shared quick-hash fingerprint of every file in this group.
+ pub quick_hash: BlakeHash,
+ /// All known file locations whose quick-hash is this value.
+ pub files: Vec,
+ /// Current verification state of this group.
+ pub verified_state: VerifiedState,
+}
+
+/// State of a candidate group's verification.
+///
+/// WHY plain external tagging (no `#[serde(tag)]`): this is a unit-only
+/// enum; internal tagging would force a TS object envelope for zero gain.
+/// If a future variant grows fields, switch to internal tagging then.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+pub enum VerifiedState {
+ /// Group has not been compared by `full_hash`.
+ Unverified,
+ /// Every file in the group produced the same `full_hash` (true duplicate).
+ VerifiedDuplicate,
+ /// At least one file differs by `full_hash` (quick-hash collision, not duplicate).
+ VerifiedDistinct,
+ /// Some files have been verified, others haven't (partial batch completion).
+ Mixed,
+}
+
+/// Per-file outcome inside `AppEvent::VerifyProgress`.
+///
+/// WHY `#[serde(tag = "outcome", content = "data")]`: matches `CoreError`'s
+/// internal-tagging-with-content pattern, producing a TypeScript discriminated
+/// union `{ outcome: "Computed"; data: … } | { outcome: "Failed"; data: … }`.
+///
+/// WHY only `Serialize` (no `Deserialize`): `FullHashOutcome::Failed` holds a
+/// `CoreError`, and `CoreError` is `!Deserialize` because `CoreError::Io` lowers
+/// `std::io::Error` (which is `!Deserialize`) at construction time. Since
+/// `FullHashOutcome` is an outbound-only event payload the frontend never sends
+/// back, `Serialize` alone is the correct bound.
+#[derive(Clone, Debug, Serialize)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+#[serde(tag = "outcome", content = "data")]
+pub enum FullHashOutcome {
+ /// Full hash was successfully computed for this file.
+ Computed {
+ /// The stable file identifier.
+ file_uuid: FileUuid,
+ /// The computed full BLAKE3-256 hash.
+ hash: BlakeHash,
+ },
+ /// Full hash computation failed for this file.
+ Failed {
+ /// The stable file identifier.
+ file_uuid: FileUuid,
+ /// The error that caused the failure.
+ error: CoreError,
+ },
+}
diff --git a/crates/core/src/errors.rs b/crates/core/src/errors.rs
index fb61df2..3f6faeb 100644
--- a/crates/core/src/errors.rs
+++ b/crates/core/src/errors.rs
@@ -65,6 +65,47 @@ pub enum CoreError {
/// Any adapter-level failure that didn't map to a typed variant.
#[error("internal: {0}")]
Internal(String),
+
+ /// A `full_hash` was requested but cannot be produced.
+ ///
+ /// WHY separate variant (not `Internal`): the frontend branches on the
+ /// inner `reason` to show distinct user-facing messages — e.g., mount the
+ /// volume vs wait for the backfill worker. `Internal` would prevent that.
+ #[error("full hash unavailable: {reason:?}")]
+ FullHashUnavailable {
+ /// The specific reason the full hash is not available.
+ reason: FullHashUnavailableReason,
+ },
+}
+
+/// Why a `full_hash` could not be produced.
+///
+/// WHY `#[serde(tag = "kind")]` (internal tagging, no content key): struct
+/// variants already carry named fields inline; internal tagging produces
+/// `{ "kind": "NotMounted", "volume_id": "…" }` — a TypeScript discriminated
+/// union the frontend switches on cleanly.
+///
+/// WHY `thiserror::Error`: each variant needs a `Display` impl so
+/// `CoreError::FullHashUnavailable` can format its `reason` field via `{reason:?}`.
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, thiserror::Error)]
+#[cfg_attr(feature = "specta", derive(specta::Type))]
+#[serde(tag = "kind")]
+pub enum FullHashUnavailableReason {
+ /// The volume that holds this file is not currently mounted.
+ #[error("volume not mounted: {volume_id}")]
+ NotMounted {
+ /// UUID string of the unmounted volume.
+ volume_id: String,
+ },
+ /// The full hash has not been computed yet (backfill not yet run).
+ #[error("full hash has not been computed for this file yet")]
+ NotComputed,
+ /// An I/O error occurred while computing the full hash.
+ #[error("io error: {message}")]
+ IoError {
+ /// The original I/O error message.
+ message: String,
+ },
}
// WHY explicit From, not #[from]: Io is now a struct variant capturing
diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs
index 72701ac..939b3b1 100644
--- a/crates/core/src/events.rs
+++ b/crates/core/src/events.rs
@@ -2,7 +2,10 @@
use serde::Serialize;
-use crate::{CoreError, MediaPath, VolumeId};
+use crate::{
+ CoreError, FileUuid, MediaPath, VolumeId,
+ dedup::{BatchId, FullHashOutcome},
+};
/// A filesystem event detected by the watcher.
///
@@ -11,6 +14,13 @@ use crate::{CoreError, MediaPath, VolumeId};
/// frontend's `'file-event'` channel listener byte-compatible. `CoreError` uses
/// `tag = "kind", content = "data"` — a different shape intentionally, because
/// `CoreError` is a Result error type while `FileEvent` is a v1-frozen channel payload.
+///
+/// WHY `file_uuid: Option