diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a8c04e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json,toml}] +indent_size = 2 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..d56a237 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +eval "$(devenv direnvrc)" +use devenv diff --git a/.frontloop/AUDIT-REMEDIATION.md b/.frontloop/AUDIT-REMEDIATION.md new file mode 100644 index 0000000..77086bd --- /dev/null +++ b/.frontloop/AUDIT-REMEDIATION.md @@ -0,0 +1,98 @@ +# Brain Brew audit remediation program + +Source of truth: `audit/16-synthesis.md`, supported by `audit/01-flow-map.md` through `audit/15-release-security.md`. + +This file defines cross-epic ordering. Within each epic, ready-task numeric prefixes are strict sequence, not estimates of parallel safety. A task whose Implementation Notes names a clarification or another epic must not start until that prerequisite is resolved. + +## Epics + +1. `release-baseline` — immutable versions, extracted crate verification, Nix, release gating, trusted channels. +2. `canonical-source-integrity` — fail-closed YAML, one source-document interface, recoverable transactions, safe CLI output. +3. `core-compose-correctness` — semantic field values, expected bases, tombstones, messages, semantic diff, typed errors. +4. `package-federation-security` — contained/authenticated packages, bounded fetches, one planner, media provenance. +5. `workbench-hardening` — containment, typed contract, security, CAS/preview/transactions, complete UI, bounded caches. +6. `translation-integrity` — structural content policy, compositional ownership, explicit deletion/adaptation, shared mutation policy. +7. `crowdanki-roundtrip` — Unicode identity, GUID/ordinal validation, reviewable import, media handoff, equivalence/reconciliation. +8. `ultimate-geography-production` — real-consumer canonical/media/translation/parity/package/release baseline. +9. `quality-docs-observability` — truthful ADRs/docs, initialization, executable examples, generated/fuzz/mutation/platform/budget gates. +10. `architecture-performance` — later structural deepening and measured optimization after correctness stabilizes. + +## Phase 0 — decisions and immediate containment + +Resolve these clarification gates before starting their dependent tasks: + +- Release version and supported channels. +- Replacement precondition representation and typed tombstone model. +- Package symlink, compatibility-version, and release-media policies. +- Workbench trust/transaction scope. +- Structural field-name, strict translation ownership, and blank translation policies. +- UG fixture contract and legacy workflow disposition. +- Public Rust crate support commitment. +- Existing `default/clarify/7500` for Anki-to-existing-source reconciliation. + +Immediate code containment that does not wait for every decision: + +1. `workbench-hardening/0010` — mark Apply experimental or make release Workbench read-only. +2. `canonical-source-integrity/0010` — reject duplicate dynamic keys. +3. `package-federation-security/0010` — central safe-relative-path authorization. +4. `core-compose-correctness/0010` — semantic field values. +5. `ultimate-geography-production/0010` — pinned/reconciled consumer baseline. +6. `quality-docs-observability/0010` — make ADRs and architecture records truthful. + +## Phase 1 — integrity foundations + +These streams may proceed in parallel, preserving each epic's numeric order: + +- `canonical-source-integrity/0010–0040`: strict decoding, source documents, transactions. +- `core-compose-correctness/0010–0050`: value semantics, preconditions, tombstones, messages, complete diff. +- `package-federation-security/0010–0030`: path authorization, immutable lock schema, safe extraction. +- `release-baseline/0010–0030`: version synchronization, extracted crates, Nix/E2E separation. +- `crowdanki-roundtrip/0010–0020`: imported stable IDs and identity validation. + +Exit: no silent source loss, no unauthenticated package-tree reads, destructive composition has complete regression coverage, and package artifacts can be verified independently. + +## Phase 2 — shared mutation, planning, and Workbench safety + +1. Complete `canonical-source-integrity/0050–0070`. +2. Complete `package-federation-security/0040–0090`. +3. Complete `translation-integrity/0010–0050` once its decisions are resolved. +4. Complete `workbench-hardening/0020–0060`; remove read-only/experimental containment only after 0030–0060 pass. +5. Complete `crowdanki-roundtrip/0030–0050`. +6. Complete `core-compose-correctness/0060` with the typed Workbench contract. + +Exit: every mutator plans and validates before writing, uses canonical document operations, has recoverable commit semantics, and carries package/media provenance. + +## Phase 3 — production Ultimate Geography acceptance + +Follow `ultimate-geography-production/0020–0100` in order. Translation tasks 0050/0060 require the translation-integrity epic; golden tasks 0070/0080 require the complete CrowdAnki oracle; packaging and final consumer gate require media ownership and release verification policy. + +In parallel after their prerequisites: + +- `release-baseline/0040–0070`. +- `quality-docs-observability/0020–0040`. + +Exit: actual UG—not only the fixture—formats, verifies all 74+26 targets with media, exports representative goldens, packages declared media reproducibly, and gates Brain Brew releases. + +## Phase 4 — product completeness + +- `workbench-hardening/0070–0110`: complete list/detail, drafts, bounded caches, accessibility/lifecycle, pivot removal. +- `crowdanki-roundtrip/0060`: implement or deprecate existing-source reconciliation after `default/7500`. +- Remaining executable docs and supported installation/Rust interface work. +- Resolve/complete existing `default/ready/0150` cross-language matrix in coordination with Workbench pagination and translation ownership; do not duplicate it. + +## Phase 5 — architecture and long-horizon quality + +Only after affected behavior is stable: + +1. `architecture-performance/0010–0040` — typed paths and implementation splits. +2. `architecture-performance/0050–0070` — measured composition, media, and list-index optimization. +3. `quality-docs-observability/0050–0100` — property, fuzz, mutation, cross-platform, accessibility/performance, and public-crate gates. + +## Global delivery rules + +- Use red-green-refactor for every behavior change. +- Each task must preserve deterministic canonical output and run the relevant Devenv gates. +- Security/correctness tasks require the reproduced audit probe as a regression test. +- Structural cleanup follows behavior fixes; do not mix mass refactors with semantic corrections. +- Real UG acceptance is mandatory before release claims are restored. +- Do not mark an epic done while any clarification that affects its shipped behavior remains unresolved. diff --git a/.frontloop/_archive/DECISIONS-2026-07-11.md b/.frontloop/_archive/DECISIONS-2026-07-11.md new file mode 100644 index 0000000..5c832f9 --- /dev/null +++ b/.frontloop/_archive/DECISIONS-2026-07-11.md @@ -0,0 +1,12 @@ +# Remediation decision record — 2026-07-11 + +These decisions close the Frontloop clarification gates created by the audit-remediation plan. + +- **Release**: next preview is `1.0.0-alpha.2`. Supported channels are crates.io (manually published once gates pass), pinned GitHub release artifacts, and a pinned Nix flake/tag channel. +- **Workbench**: local user-selected workspaces are trusted; the experimental local API may be reached by any local browser page; Apply rejects cross-root and cross-filesystem batches. This is not a release-safe browser trust boundary. +- **Translations**: field-name translations are optional display labels and do not count toward required coverage by default. True Anki identifier renames require an explicit atomic validated operation. Ownership belongs to the overlay that introduces a unit; completeness is checked across the final target stack. Blank direct translations are invalid; explicit path-scoped deletion/adaptation intents are required. +- **Ultimate Geography fixture**: Brain Brew maintains an exact pinned production snapshot plus explicit reviewed migration transform and requires fixture plus live-consumer gates. UG legacy workflow choices are out of scope for Brain Brew and belong to the external UG migration PR. +- **Rust crates**: `brain-brew-core` and `brain-brew-formats` are implementation packages for alpha.2, without a supported public Rust API commitment. +- **Core replacement safety**: complete replacements use canonical stable fingerprints; sparse changes use typed property-level expectations. +- **Core tombstones**: typed entity/path variants remain canonical, with a compatibility reader for the prior flat representation. +- **CrowdAnki pull-back**: defer the optional Anki/CrowdAnki-to-source workflow until a real maintainer requests it; it is not current Brain Brew scope. diff --git a/.frontloop/_archive/canonical-source-integrity/done/0010-reject-duplicate-keys-in-every-dynamic-yaml-map.md b/.frontloop/_archive/canonical-source-integrity/done/0010-reject-duplicate-keys-in-every-dynamic-yaml-map.md new file mode 100644 index 0000000..9f9bd4b --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0010-reject-duplicate-keys-in-every-dynamic-yaml-map.md @@ -0,0 +1,50 @@ +--- +title: Reject duplicate keys in every dynamic YAML map +priority: critical +--- + +## Goal + +Make canonical deck, overlay, translation, manifest, lock, and media-map decoders reject duplicate IDs or fields before any value is overwritten. + +## Acceptance Criteria + +- Duplicate keys fail at all dynamic map levels identified by the audit +- Errors name the source file, schema path, and duplicate key where available +- `fmt` and every mutator leave bytes unchanged on duplicate-key failure +- Regression tests cover overlays, contextual translations, manifests, locks, media maps, notes, and targets +- Canonical positive round trips remain byte-stable + +## Implementation Notes + +First implementation task in this epic; use TDD against the reproduced data-loss probes. + + +## Completion Summary + +- Added recursive fail-closed duplicate-key detection before serde map deserialization +- Wired strict duplicate validation through canonical decks, overlays/translations, manifests, locks, media maps, includes, CLI mutators, verify, and Workbench +- Added source/schema/key diagnostics and byte-unchanged failure regressions +- Added focused coverage for notes, targets, contextual translations, overlays, locks, manifests, media maps, and canonical round trips +- Passed delegated fmt, 413-test non-browser suite, clippy, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/strict_yaml.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/src/manifest.rs +- crates/brain-brew-formats/src/lockfile.rs +- crates/brain-brew-formats/src/media_map.rs +- crates/brain-brew-formats/src/source_includes.rs +- crates/brain-brew-formats/src/lib.rs +- crates/brain-brew-formats/tests/canonical_yaml.rs +- crates/brain-brew-formats/tests/overlay_yaml.rs +- crates/brain-brew-formats/tests/manifest_yaml.rs +- crates/brain-brew-formats/tests/lockfile_yaml.rs +- crates/brain-brew-formats/tests/media_map.rs +- crates/brain-brew-cli/src/io.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/src/commands/media.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/cli.rs diff --git a/.frontloop/_archive/canonical-source-integrity/done/0020-validate-yaml-unions-and-scalar-types-before-conversion.md b/.frontloop/_archive/canonical-source-integrity/done/0020-validate-yaml-unions-and-scalar-types-before-conversion.md new file mode 100644 index 0000000..0df52e7 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0020-validate-yaml-unions-and-scalar-types-before-conversion.md @@ -0,0 +1,50 @@ +--- +title: Validate YAML unions and scalar types before conversion +priority: critical +--- + +## Goal + +Reject conflicting, orphaned, or partial field/message/image/media alternatives and unintended YAML scalar coercions instead of accepting then discarding data. + +## Acceptance Criteria + +- Each union shape has exactly one valid representation +- Conflicting or incomplete structured values fail with schema-location diagnostics +- Boolean, null, and numeric scalars are rejected where canonical strings are required +- Public emitters return errors rather than panic on constructible invalid map keys +- A malformed-union matrix and hostile scalar corpus cover all decoder entry points + +## Implementation Notes + +Depends on duplicate-aware decoding so one strict decoder path owns all failures. + + +## Completion Summary + +- Added fail-closed schema validation for malformed field, message, image, media, and overlay union representations +- Rejected unintended boolean, null, and numeric coercion at canonical string positions while preserving intentional typed schema values +- Added schema-path diagnostics and unchanged-byte CLI failure coverage +- Made overlay, manifest, and lock emitters fallible and validated constructible invalid states instead of panicking +- Added malformed-union/scalar matrices and preserved UG canonical formatting, idempotence, and all-target composition +- Passed delegated fmt, full non-browser tests, clippy, focused fixture tests, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/strict_yaml.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/src/manifest.rs +- crates/brain-brew-formats/src/lockfile.rs +- crates/brain-brew-formats/src/media_map.rs +- crates/brain-brew-formats/tests/yaml_schema_strictness.rs +- crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs +- crates/brain-brew-formats/tests/emitter_roundtrip.rs +- crates/brain-brew-formats/tests/overlay_yaml.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/commands/diff.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/media_includes.rs +- crates/brain-brew-cli/tests/translations_cli.rs diff --git a/.frontloop/_archive/canonical-source-integrity/done/0030-introduce-include-preserving-canonical-source-document-modules.md b/.frontloop/_archive/canonical-source-integrity/done/0030-introduce-include-preserving-canonical-source-document-modules.md new file mode 100644 index 0000000..7d41845 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0030-introduce-include-preserving-canonical-source-document-modules.md @@ -0,0 +1,39 @@ +--- +title: Introduce include-preserving canonical source document modules +priority: critical +--- + +## Goal + +Create deep `CanonicalSourceDocument` and `OverlaySourceDocument` modules in formats that expose validated edits while preserving include directives and canonical emission. + +## Acceptance Criteria + +- Deck and overlay documents preserve scalar/media includes through unrelated edits +- Typed operations cover field, translation, media, and metadata changes needed by current mutators +- All operations enforce duplicate, union, and canonical invariants +- The interface returns source/schema-aware errors without filesystem dependencies +- Round-trip and edit-locality tests demonstrate unchanged unrelated bytes or defined canonical changes + +## Implementation Notes + +Depends on strict decoders; this becomes the sole source-mutation seam for later tasks. + + +## Completion Summary + +- Added pure include-preserving CanonicalSourceDocument and OverlaySourceDocument modules in brain-brew-formats +- Kept YAML representation private while exposing typed metadata, field, translation, media, image, stale-resolution, and import construction edits +- Routed included scalar/media edits to provenance-tagged outputs while preserving unrelated directives and canonical root bytes +- Made edits atomic by validating cloned state before commit and reused strict duplicate/union/scalar/canonical invariants +- Added exact-byte locality, include routing, source/schema diagnostics, translation/media/image, and idempotence tests +- Passed focused formats tests, full repository tests, fmt, clippy, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/canonical_source_document.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/source_includes.rs +- crates/brain-brew-formats/src/lib.rs +- crates/brain-brew-formats/tests/source_documents.rs diff --git a/.frontloop/_archive/canonical-source-integrity/done/0040-build-a-journaled-recoverable-workspace-transaction-module.md b/.frontloop/_archive/canonical-source-integrity/done/0040-build-a-journaled-recoverable-workspace-transaction-module.md new file mode 100644 index 0000000..a777fb2 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0040-build-a-journaled-recoverable-workspace-transaction-module.md @@ -0,0 +1,40 @@ +--- +title: Build a journaled recoverable workspace transaction module +priority: critical +--- + +## Goal + +Replace misleading sequential-renames atomicity with plan, validate, commit, rollback, and restart recovery for multi-file workspace writes. + +## Acceptance Criteria + +- All target files and expected fingerprints are validated before mutation +- Commit records a journal and recoverable backups before the first replacement +- Injected failures at every commit step either roll back or leave an explicit recoverable state +- Cross-filesystem and external-include cases are rejected or handled according to a documented contract +- Crash/restart recovery tests prove no silently mixed old/new workspace + +## Implementation Notes + +May be developed beside source-document modules; filesystem implementation belongs in CLI. + + +## Completion Summary + +- Added a CLI-owned typed workspace transaction plan with canonical-root, target-type, duplicate, expected fingerprint/existence, and same-filesystem validation +- Persisted durable journals, staged replacements, complete backups, commit progress, rollback state, and cooperative locking before replacements +- Added deterministic failure injection across prepare, backup, replace, finalize, rollback, and restart recovery +- Rejected external-root, cross-filesystem, symlink-escape, unsupported, and conflicting target plans under a documented fail-closed contract +- Fixed judge-found stale rollback restore-stage recovery wedge and proved attacker-modified derived stages are discarded and regenerated from verified backups +- Documented durability, platform, recovery, and migration contracts; left existing mutators unmigrated for tasks 0050/0060 +- Passed focused transaction tests, full fmt/test/clippy, parent full CI, and independent Claude re-judgment + +### Files Changed + +- Cargo.lock +- crates/brain-brew-cli/Cargo.toml +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/workspace_transaction.rs +- documentation/docs/reference/workspace-transactions.md +- documentation/sidebars.js diff --git a/.frontloop/_archive/canonical-source-integrity/done/0050-migrate-formatting-and-translation-mutations-to-source-documents.md b/.frontloop/_archive/canonical-source-integrity/done/0050-migrate-formatting-and-translation-mutations-to-source-documents.md new file mode 100644 index 0000000..71e0d26 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0050-migrate-formatting-and-translation-mutations-to-source-documents.md @@ -0,0 +1,46 @@ +--- +title: Migrate formatting and translation mutations to source documents +priority: high +--- + +## Goal + +Remove direct serde-value and line-surgery paths from `fmt` and translation insertion/resolution, using the canonical source-document and transaction modules. + +## Acceptance Criteria + +- Formatting and translation writes use typed document operations +- Include-bearing files remain canonical after edits +- All multi-file translation operations are plan-first and recoverable +- Existing formatting/idempotence and stale-translation behavior remain green +- Direct mutation implementations are deleted rather than retained as fallbacks + +## Implementation Notes + +Depends on source-document and transaction modules. + + +## Completion Summary + +- Migrated canonical and overlay fmt paths to typed source documents with strict validation and canonical emission +- Migrated all CLI translation apply, interactive/contextual insertion, stale confirm/replace/batch, and source-impact writes to typed overlay operations +- Added plan-first affected-target composition validation, expected fingerprints, recovery-first behavior, and one journaled transaction per operation +- Removed legacy YAML line-surgery, serde-value mutation, and direct command write fallback implementations +- Preserved unrelated scalar includes, canonical idempotence, stale precedence, and shadowed current translation decisions +- Documented canonical rewrite and transaction behavior; retained format-specific strict codecs where appropriate +- Passed full fmt/test/clippy, focused CLI/formats/UG suites, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-cli/src/commands/fmt.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/io.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/workspace_mutation.rs +- crates/brain-brew-cli/src/workspace_transaction.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/translations_cli.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/tests/source_documents.rs +- documentation/docs/authoring/translations.md +- documentation/docs/reference/workspace-transactions.md diff --git a/.frontloop/_archive/canonical-source-integrity/done/0060-migrate-media-import-and-workbench-writes-to-safe-mutation-modules.md b/.frontloop/_archive/canonical-source-integrity/done/0060-migrate-media-import-and-workbench-writes-to-safe-mutation-modules.md new file mode 100644 index 0000000..a9dac6b --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0060-migrate-media-import-and-workbench-writes-to-safe-mutation-modules.md @@ -0,0 +1,55 @@ +--- +title: Migrate media import and Workbench writes to safe mutation modules +priority: high +--- + +## Goal + +Move media hash/images-to-refs, CrowdAnki import output, and Workbench Apply onto shared source-document and workspace-transaction semantics. + +## Acceptance Criteria + +- Media commands perform complete validation before changing any source +- Import refuses overwrite without explicit force and writes transactionally +- Workbench edits cannot bypass canonical document emission +- Locked dependency sources cannot be selected for mutation +- Failure injection covers every command family and leaves no partial output + +## Implementation Notes + +Depends on source-document/transaction modules plus package ownership work for locked dependencies. + + +## Blocked + +Blocked on package-federation-security/0010–0070: safe path authorization, immutable lock containment, registry-aware provenance planning, and federated media ownership must land before mutators can prove locked dependency sources are unselectable. Resume this task immediately after those prerequisites integrate. + + +## Completion Summary + +- Completed media mutator audit and retained typed source-document, ownership, and recoverable transaction-only writes +- Migrated CrowdAnki import to typed canonical source construction, default overwrite refusal, explicit --force, safe authorization, expected-state backups, and one recoverable transaction +- Migrated every development Workbench source, translation, metadata, include, and new-language write to typed source documents and one journaled transaction +- Removed generic YAML AST, line mutation, temp/partial rename, and direct canonical write paths +- Added root/locked ownership guards and exact proposed-state validation before commit +- Fixed judge-found intra-request T0 fingerprint and absent-target publication races, preserving external bytes with typed conflicts +- Added command-family failure/restart and root/include/overlay/manifest/new-file race regressions +- Passed full default/dev tests, fmt, both clippy modes, UI/embed, 13 E2E, 74+26 fixture verification, docs, release smoke, and Claude re-judgment + +### Files Changed + +- crates/brain-brew-cli/src/args.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/commands/import.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/src/workspace_mutation.rs +- crates/brain-brew-cli/src/workspace_transaction.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/canonical_source_document.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/tests/source_documents.rs +- crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs +- documentation/docs/reference/cli.md +- documentation/docs/reference/workbench.md +- documentation/docs/reference/workspace-transactions.md diff --git a/.frontloop/_archive/canonical-source-integrity/done/0070-normalize-cli-output-safety-and-source-diagnostics.md b/.frontloop/_archive/canonical-source-integrity/done/0070-normalize-cli-output-safety-and-source-diagnostics.md new file mode 100644 index 0000000..ec159c6 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/done/0070-normalize-cli-output-safety-and-source-diagnostics.md @@ -0,0 +1,57 @@ +--- +title: Normalize CLI output safety and source diagnostics +priority: medium +--- + +## Goal + +Give compose, export, import, diff, help, and JSON modes consistent parent creation, clean-output, overwrite, exit-code, and filename-rich error behavior. + +## Acceptance Criteria + +- Compose creates declared output parents or reports a precise failure +- Export uses a clean transaction and cannot retain stale files +- Import validates all options and requires explicit overwrite intent +- Parse and schema errors include the relevant source filename +- JSON-capable commands emit structured errors consistently, including translations +- Diff supports an optional change-sensitive exit code and trailing arguments are validated + +## Implementation Notes + +Final source-integrity UX task after shared transaction behavior exists. + + +## Completion Summary + +- Added recoverable compose/import file publication with safe parent creation and explicit overwrite semantics +- Added clean staged CrowdAnki output-tree publication with full validation, force/backup/journal/recovery, and stale-file elimination +- Made import/help/version/all CLI flag parsing strict for unknown, duplicate, conflicting, and trailing arguments +- Added source/schema/JSON-path-rich diagnostics including CrowdAnki nested paths +- Introduced one versioned structured JSON error envelope across all --json routes including pre-dispatch and translations errors +- Added opt-in diff exit status contract: 0 no changes, 2 differences, 1 operational error +- Added output failure injection and dirty-tree regressions +- Passed full Rust tests, fmt/clippy, E2E, docs, release smoke, CLI/CrowdAnki contract tests, and Claude judgment + +### Files Changed + +- Cargo.lock +- crates/brain-brew-cli/src/args.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/output.rs +- crates/brain-brew-cli/src/output_transaction.rs +- crates/brain-brew-cli/src/workspace_mutation.rs +- crates/brain-brew-cli/src/media_assets.rs +- crates/brain-brew-cli/src/commands/compose.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/import.rs +- crates/brain-brew-cli/src/commands/diff.rs +- crates/brain-brew-cli/tests/cli_contract.rs +- crates/brain-brew-cli/tests/translations_cli.rs +- crates/brain-brew-formats/Cargo.toml +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- documentation/docs/authoring/diff-explain.md +- documentation/docs/authoring/verify-export.md +- documentation/docs/reference/cli.md +- documentation/docs/reference/workspace-transactions.md diff --git a/.frontloop/_archive/canonical-source-integrity/epic.md b/.frontloop/_archive/canonical-source-integrity/epic.md new file mode 100644 index 0000000..8200832 --- /dev/null +++ b/.frontloop/_archive/canonical-source-integrity/epic.md @@ -0,0 +1,15 @@ +--- +title: Canonical source integrity and safe mutation +slug: canonical-source-integrity +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Make every canonical YAML read, format, and mutation path fail closed and preserve maintainer intent. Introduce one include-preserving source-document module and one recoverable workspace transaction module, then migrate all mutators to those interfaces. + +## Sequence + +Close decoder data-loss defects first; establish source-document and transaction modules next; migrate command families only after those invariants exist; finish with destructive-output and diagnostic consistency. diff --git a/.frontloop/_archive/core-compose-correctness/done/0010-model-scalar-image-and-message-fields-as-semantic-values.md b/.frontloop/_archive/core-compose-correctness/done/0010-model-scalar-image-and-message-fields-as-semantic-values.md new file mode 100644 index 0000000..c4c9c83 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0010-model-scalar-image-and-message-fields-as-semantic-values.md @@ -0,0 +1,64 @@ +--- +title: Model scalar image and message fields as semantic values +priority: critical +--- + +## Goal + +Remove empty-string placeholders so composition, blank checks, fills, overrides, validation, and differences recognize every field representation. + +## Acceptance Criteria + +- Core exposes one semantic field-value abstraction covering scalar, structured image, and structured message content +- Add/merge/fill cannot erase an existing structured value by treating it as blank +- Unknown structured-media references fail validation +- Rendering and CrowdAnki lowering remain deterministic +- Regression tests cover every intent against each value representation + +## Implementation Notes + +First implementation task; prerequisite for reliable expected-base and semantic-diff work. + + +## Completion Summary + +- Introduced one pure core FieldValue/FieldMap abstraction for scalar, structured image, and structured message note fields +- Removed split scalar/image/message maps and empty-string placeholder states across core, formats, CLI, Workbench, media, and adapters +- Made blank/fill/add/merge/replace/override/expected-base behavior representation-aware with exhaustive intent matrix coverage +- Moved unknown structured media, malformed value, message reference, and message cycle checks into canonical validation with note/field paths +- Updated translation, message resolution, rendering, YAML/source documents, CrowdAnki import/export, Workbench staging, media scanning, and semantic differences +- Added structured expected-base YAML and ADR-0018 while preserving canonical syntax and UG/CrowdAnki parity +- Passed CPU-bounded full tests, clippy, 74+26 parity, E2E, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/src/validate.rs +- crates/brain-brew-core/src/messages.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/tests/canonical_deck_validation.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-core/tests/semantic_diff.rs +- crates/brain-brew-core/tests/translation_coverage.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/src/media.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/source_includes.rs +- crates/brain-brew-formats/src/canonical_source_document.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/tests/canonical_yaml.rs +- crates/brain-brew-formats/tests/overlay_yaml.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/media_references.rs +- crates/brain-brew-formats/tests/emitter_roundtrip.rs +- crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/src/overlay_draft.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/cli.rs +- documentation/docs/reference/decisions/0018-model-note-fields-as-semantic-values.md +- documentation/docs/reference/decisions/0016-use-structured-image-field-references-and-severable-media-includes.md +- documentation/docs/reference/decisions/README.md diff --git a/.frontloop/_archive/core-compose-correctness/done/0020-enforce-real-expected-base-equality-for-destructive-changes.md b/.frontloop/_archive/core-compose-correctness/done/0020-enforce-real-expected-base-equality-for-destructive-changes.md new file mode 100644 index 0000000..8b92262 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0020-enforce-real-expected-base-equality-for-destructive-changes.md @@ -0,0 +1,61 @@ +--- +title: Enforce real expected-base equality for destructive changes +priority: critical +--- + +## Goal + +Apply the approved precondition model to complete and media replacements, with deterministic mismatch errors and no presence-only escape. + +## Acceptance Criteria + +- Note, note-type, field, template, and media complete replacements compare the actual prior entity/value +- Wrong-but-present baselines fail before mutation +- Override intent cannot silently bypass stale-overlay protection +- Diagnostics identify path, expected precondition, and actual state without requiring English parsing +- A mutation matrix covers matching, missing, stale, and concurrent overlay cases + +## Implementation Notes + +Depends on semantic field values and the replacement-precondition decision. + + +## Completion Summary + +- Added canonical SHA-256 v1 EntityFingerprint with algorithm/domain/entity-kind/version separation and strict canonical parsing +- Applied actual-current entity fingerprints to complete note, note-type, field-definition, card-template, and media replace/override/remove preconditions +- Preserved exact typed expected values for sparse changes and rejected presence-only legacy baselines with migration guidance +- Added structured compose/explain diagnostics carrying path, entity kind, intent, overlay, expected, actual, code, and category +- Made diff --as-overlay generate prior fingerprints automatically and fail closed for unsupported ordering +- Added golden vectors, per-semantic-property mutation tests, complete mutation/precondition matrix, ordered concurrent-overlay tests, YAML migration tests, and UG-style fixture migration +- Documented ADR-0019 and the canonical entity fingerprint specification +- Passed full CPU-bounded tests, clippy, docs, release smoke, controlled 13/13 baseline and payload E2E reruns, and final Claude ACCEPT + +### Files Changed + +- CHANGELOG.md +- Cargo.toml +- Cargo.lock +- crates/brain-brew-core/Cargo.toml +- crates/brain-brew-core/src/fingerprint.rs +- crates/brain-brew-core/src/lib.rs +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/tests/entity_fingerprint.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/tests/overlay_yaml.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/overlay_draft.rs +- crates/brain-brew-cli/src/commands/explain.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/federated_media_ownership.rs +- fixtures/ug-style/tombstone-australia.yaml +- documentation/docs/reference/entity-fingerprints.md +- documentation/docs/reference/yaml.md +- documentation/docs/concepts/overlays.md +- documentation/docs/authoring/diff-explain.md +- documentation/docs/reference/decisions/0007-require-explicit-conflict-and-destructive-change-semantics.md +- documentation/docs/reference/decisions/0018-model-note-fields-as-semantic-values.md +- documentation/docs/reference/decisions/0019-use-canonical-entity-fingerprints-for-complete-destructive-changes.md +- documentation/docs/reference/decisions/README.md diff --git a/.frontloop/_archive/core-compose-correctness/done/0030-implement-typed-tombstones-for-every-removable-entity.md b/.frontloop/_archive/core-compose-correctness/done/0030-implement-typed-tombstones-for-every-removable-entity.md new file mode 100644 index 0000000..7cf7a43 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0030-implement-typed-tombstones-for-every-removable-entity.md @@ -0,0 +1,66 @@ +--- +title: Implement typed tombstones for every removable entity +priority: high +--- + +## Goal + +Prevent removed identities from being reused across or within entity kinds and preserve removal provenance through composition. + +## Acceptance Criteria + +- The selected typed/path tombstone representation is implemented +- All removable entity kinds create and consult tombstones +- Legacy tombstones migrate or fail according to the approved policy +- Cross-kind identical stable IDs no longer alias +- Composition and YAML tests cover removal/reintroduction across overlay stacks + +## Implementation Notes + +Depends on the tombstone decision and strict YAML migration support. + + +## Completion Summary + +- Added ordered typed TombstoneAddress variants with full parent scope, TombstoneRecord, and deterministic RemovalProvenance +- Made every current mutation address consult exact and ancestor tombstones before all intents and every remove operation record provenance without resurrection +- Prevented exact-address reuse while allowing identical StableIds across entity kinds, siblings, and different parent scopes +- Added strict flat legacy YAML inference only for exactly one retained top-level note/note-type/media identity and actionable failure for unknown, ambiguous, or nested IDs +- Made canonical YAML always emit explicit typed path records with stable provenance round-trips and strict kind/path validation +- Migrated validation, translation, media, CrowdAnki, semantic diff, CLI JSON/export, and Workbench active-entity behavior to typed blocking semantics +- Added comprehensive kind/parent/order/reuse/duplicate/provenance/migration tests and ADR-0020 +- Passed focused/full constituent suites, UG 74+26 mutation regressions, clippy, E2E, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/src/validate.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/src/tests.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-core/tests/canonical_deck_validation.rs +- crates/brain-brew-core/tests/content_validation.rs +- crates/brain-brew-core/tests/semantic_diff.rs +- crates/brain-brew-core/tests/translation_coverage.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/src/media.rs +- crates/brain-brew-formats/tests/canonical_yaml.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/emitter_roundtrip.rs +- crates/brain-brew-formats/tests/media_references.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs +- crates/brain-brew-cli/src/output.rs +- crates/brain-brew-cli/src/commands/explain.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/ug_style_fixture.rs +- documentation/docs/reference/decisions/0020-address-removals-with-typed-path-tombstones.md +- documentation/docs/reference/decisions/0007-require-explicit-conflict-and-destructive-change-semantics.md +- documentation/docs/reference/decisions/0019-use-canonical-entity-fingerprints-for-complete-destructive-changes.md +- documentation/docs/reference/decisions/README.md +- documentation/docs/reference/yaml.md +- documentation/docs/concepts/canonical-deck.md +- documentation/docs/reference/glossary.md diff --git a/.frontloop/_archive/core-compose-correctness/done/0040-resolve-structured-messages-as-a-validated-dependency-graph.md b/.frontloop/_archive/core-compose-correctness/done/0040-resolve-structured-messages-as-a-validated-dependency-graph.md new file mode 100644 index 0000000..c9d04b8 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0040-resolve-structured-messages-as-a-validated-dependency-graph.md @@ -0,0 +1,52 @@ +--- +title: Resolve structured messages as a validated dependency graph +priority: high +--- + +## Goal + +Replace stale-snapshot rendering with deterministic dependency-order resolution and explicit missing-reference/cycle failures. + +## Acceptance Criteria + +- Multi-hop references resolve from current upstream values +- Cycles are detected with a useful path trace +- Missing variables and swallowed render errors are surfaced consistently +- Resolution is deterministic regardless of map insertion order +- Tests cover chains, diamonds, cycles, mixed images/messages, and overlay updates + +## Implementation Notes + +Build on semantic field values; keep core pure. + + +## Completion Summary + +- Added one pure per-note FieldGraph planner/resolver shared by validation, rendering, translation, CrowdAnki, CLI verify, and Workbench +- Resolved scalar/message/image chains from final semantic values with dependency-first memoization and once-only diamond lowering +- Added typed missing note/definition/value, tombstoned dependency, invalid target/image/message/reference, and cycle diagnostics +- Canonicalized closed cycle traces independent of map insertion, root traversal, and unrelated fields +- Removed stale snapshot rendering, one-hop resolution, duplicate validation paths, and swallowed Workbench render fallbacks +- Preserved reusable structured translation reference edges so later upstream overlays update downstream output +- Added comprehensive chain/diamond/self/multi/tail-cycle/missing/image/overlay/translation/insertion-order tests and documentation +- Passed CPU-bounded full tests including UG 74+26, clippy, serialized 13-test E2E, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/messages.rs +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/validate.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/tests/message_graph.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-core/tests/translation_coverage.rs +- crates/brain-brew-formats/tests/canonical_yaml.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/commands/translation_overlay.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- documentation/docs/authoring/translations.md +- documentation/docs/reference/yaml.md diff --git a/.frontloop/_archive/core-compose-correctness/done/0050-make-semantic-diff-complete-and-law-tested.md b/.frontloop/_archive/core-compose-correctness/done/0050-make-semantic-diff-complete-and-law-tested.md new file mode 100644 index 0000000..6773619 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0050-make-semantic-diff-complete-and-law-tested.md @@ -0,0 +1,47 @@ +--- +title: Make semantic diff complete and law-tested +priority: critical +--- + +## Goal + +Turn semantic diff into a trustworthy equivalence oracle covering every canonical property used by round-trip and parity tests. + +## Acceptance Criteria + +- Deck identity, adapter IDs, structured messages/images, ordering-sensitive schema, configuration, and all canonical fields participate +- Each single-property mutation is detected by a generated test matrix +- Equivalent normalized forms compare equal only where explicitly documented +- CrowdAnki and UG parity tests use the completed oracle +- Diff output remains deterministic and path-addressed + +## Implementation Notes + +Depends on semantic field values; should land before new goldens rely on it. + + +## Completion Summary + +- Moved exact canonical semantic diff into a dedicated compiler-auditable module with exhaustive no-wildcard struct and enum traversal +- Covered every current deck, note-type, ordered field/template, note, FieldValue/message/image, media, adapter-ID, and typed tombstone provenance property +- Added deterministic structured before/after changes with stable path sorting and injective semantic paths +- Added a 40-case single-property mutation inventory, all 22 tombstone address variants, and identity/determinism/inversion/order/representation/equality laws +- Defined crowdanki-export-import-v1 as an explicit six-loss owned projection while keeping the exact oracle uncompromised +- Migrated CrowdAnki, CLI UG-style, all 74 main targets, all 26 companion targets, and optional release-oracle parity to projection plus exact semantic diff or explicit collision-loss reporting +- Removed the legacy incomplete diff and old CrowdAnki spot-check comparator +- Passed 558 CPU-bounded non-E2E tests, core/CrowdAnki/UG suites, clippy, 13 E2E tests, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/semantic_diff.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/src/lib.rs +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/tests.rs +- crates/brain-brew-core/src/validate.rs +- crates/brain-brew-core/tests/semantic_diff_laws.rs +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/tests/ug_style_fixture.rs +- documentation/docs/authoring/diff-explain.md diff --git a/.frontloop/_archive/core-compose-correctness/done/0060-expose-typed-composition-and-validation-errors.md b/.frontloop/_archive/core-compose-correctness/done/0060-expose-typed-composition-and-validation-errors.md new file mode 100644 index 0000000..249c854 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/0060-expose-typed-composition-and-validation-errors.md @@ -0,0 +1,58 @@ +--- +title: Expose typed composition and validation errors +priority: medium +--- + +## Goal + +Preserve validation categories and paths through composition so CLI, Workbench, and tests do not parse flattened English messages. + +## Acceptance Criteria + +- Composition errors retain machine-readable category, DeckPath, overlay/source attribution, and conflict metadata +- Final validation is not collapsed into one opaque variant +- CLI text and JSON renderers consume the same typed errors +- Workbench returns a versioned error envelope +- Compatibility impact on public core interfaces is documented + +## Implementation Notes + +After core behavior stabilizes; coordinate with typed Workbench contract. + + +## Completion Summary + +- Added exhaustive owned `DomainDiagnostic` projections for all public composition and validation error variants, including source/overlay/path/address, expected/actual, conflicts, tombstones, FieldGraph details, and ordered child errors +- Migrated composition-backed CLI routes to the stable `diagnostics-v1` JSON envelope and typed human rendering +- Replaced Workbench English-prefix status classification and serialized-string bridges with structural typed errors and one versioned HTTP envelope +- Preserved typed diagnostics through Workbench browse, apply preview, edits, and new-language validation; development writes return 422 Domain envelopes rather than adapter 500s +- Kept lenient translation browsing usable while strict validation exposes typed stale/conflict diagnostics +- Added exhaustive projection, CLI route, Workbench API, UI unit, and browser E2E coverage for structured preview and 422 apply failures +- Refreshed checked-in Workbench assets and diagnostics/Workbench documentation +- Passed full sequential tests, default and development-write clippy, focused dev-write tests, E2E, docs, embed checks, release smoke, and Claude re-judgment + +### Files Changed + +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/tests.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-cli/src/commands/compose.rs +- crates/brain-brew-cli/src/commands/explain.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/translation_overlay.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/validate.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/output.rs +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/cli_contract.rs +- crates/brain-brew-workbench-ui/src/lib.rs +- crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs +- crates/brain-brew-cli/assets/workbench/index.html +- documentation/docs/reference/diagnostics.md +- documentation/docs/reference/cli.md +- documentation/docs/reference/workbench.md diff --git a/.frontloop/_archive/core-compose-correctness/done/choose-destructive-replacement-precondition-representation.md b/.frontloop/_archive/core-compose-correctness/done/choose-destructive-replacement-precondition-representation.md new file mode 100644 index 0000000..02ed011 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/choose-destructive-replacement-precondition-representation.md @@ -0,0 +1,23 @@ +--- +title: Choose destructive replacement precondition representation +priority: critical +--- + +## Goal + +Choose the semantic precondition complete entity replacements must compare against instead of merely checking `Option::is_some()`. + +## Acceptance Criteria + +- Select canonical entity summaries, stable fingerprints, sparse property-only replacement, or removal of complete replacement interfaces +- Define compatibility and migration behavior for existing overlay YAML +- Specify mismatch diagnostics and override interaction +- Record examples for note types, notes, fields, templates, and media + +## Implementation Notes + +Blocks expected-base implementation. + +## Questions + +### Q1: Recommended: use stable canonical fingerprints for complete entities while retaining typed property-level expected values for sparse changes. Which representation should become authoritative? diff --git a/.frontloop/_archive/core-compose-correctness/done/choose-typed-tombstone-model-and-migration-format.md b/.frontloop/_archive/core-compose-correctness/done/choose-typed-tombstone-model-and-migration-format.md new file mode 100644 index 0000000..4d0239b --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/done/choose-typed-tombstone-model-and-migration-format.md @@ -0,0 +1,23 @@ +--- +title: Choose typed tombstone model and migration format +priority: high +--- + +## Goal + +Define path- or entity-typed tombstones so removals cannot alias unrelated stable IDs and all removable entity kinds receive explicit reuse protection. + +## Acceptance Criteria + +- Choose typed entity variants or DeckPath-addressed tombstones +- Cover notes, note types, fields, templates, media, and future entity kinds +- Define YAML compatibility/migration from the flat stable-ID set +- Specify validation and diagnostic behavior for reuse attempts + +## Implementation Notes + +Blocks typed tombstone implementation but not semantic field-value work. + +## Questions + +### Q1: Recommended: use typed entity/path variants serialized explicitly, with a compatibility reader and canonical writer migration. Approve or choose an alternative. diff --git a/.frontloop/_archive/core-compose-correctness/epic.md b/.frontloop/_archive/core-compose-correctness/epic.md new file mode 100644 index 0000000..463a131 --- /dev/null +++ b/.frontloop/_archive/core-compose-correctness/epic.md @@ -0,0 +1,15 @@ +--- +title: Core composition correctness +slug: core-compose-correctness +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Restore fail-closed semantics in the pure domain: representation-aware field values, real destructive-change preconditions, typed tombstones, correct structured-message resolution, complete semantic differences, and machine-readable errors. + +## Sequence + +Decide precondition and tombstone representations first. Implement semantic field values before destructive checks, then messages and semantic diff, and finish by stabilizing error interfaces and exhaustive regression laws. diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0010-generate-collision-resistant-unicode-safe-imported-stable-ids.md b/.frontloop/_archive/crowdanki-roundtrip/done/0010-generate-collision-resistant-unicode-safe-imported-stable-ids.md new file mode 100644 index 0000000..8fcf7aa --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0010-generate-collision-resistant-unicode-safe-imported-stable-ids.md @@ -0,0 +1,45 @@ +--- +title: Generate collision-resistant Unicode-safe imported stable IDs +priority: critical +--- + +## Goal + +Replace ASCII-only first-field slugging with deterministic Unicode-aware suggestions and explicit collision handling. + +## Acceptance Criteria + +- Non-Latin notes do not collapse to `note.unnamed` +- Suggestions are deterministic across platforms and locale settings +- Collisions receive stable disambiguation or require explicit override +- Diagnostics never advertise a nonexistent override workflow +- Tests cover Cyrillic, CJK, RTL, repeated first fields, blanks, and normalization equivalents + +## Implementation Notes + +First CrowdAnki task; preserve source GUIDs independently of suggested stable IDs. + + +## Completion Summary + +- Replaced ASCII-only imported-note slugging with NFC-normalized, locale-independent suggestion generation +- Prevented non-Latin and blank first fields from collapsing to note.unnamed +- Added deterministic GUID-assisted digest disambiguation for repeated readable slugs and fallback names +- Kept CrowdAnki GUID as an independent adapter ID rather than conflating it with the suggested stable ID +- Made input-order-independent collision grouping and fail-closed final-ID collision handling explicit +- Removed nonexistent override wording and documented the automatic-only import policy and re-import stability tradeoff +- Added unit, importer, and CLI tests covering Cyrillic, CJK, RTL, blanks, repeats, normalization equivalence, GUID preservation, duplicate GUID rejection, and ordering +- Passed default full workspace tests, targeted format/CLI tests, fmt, clippy, docs, release smoke, parallelism check, and Claude judgment + +### Files Changed + +- Cargo.toml +- Cargo.lock +- crates/brain-brew-formats/Cargo.toml +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-cli/tests/cli.rs +- documentation/docs/authoring/importing-crowdanki.md +- documentation/docs/concepts/media.md +- documentation/docs/reference/cli.md +- documentation/sidebars.js diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0020-reject-guid-and-template-ordinal-identity-defects.md b/.frontloop/_archive/crowdanki-roundtrip/done/0020-reject-guid-and-template-ordinal-identity-defects.md new file mode 100644 index 0000000..d94bf58 --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0020-reject-guid-and-template-ordinal-identity-defects.md @@ -0,0 +1,40 @@ +--- +title: Reject GUID and template ordinal identity defects +priority: critical +--- + +## Goal + +Fail closed on duplicate/effectively colliding note GUIDs and non-unique, gapped, or invalid template ordinals rather than normalizing identity silently. + +## Acceptance Criteria + +- Duplicate GUIDs fail with all affected note indices +- GUID normalization/effective-collision rules are documented and tested +- Template ordinals must satisfy the supported uniqueness/contiguity model +- Import/export cannot silently reorder or renumber malformed templates +- Regression tests cover duplicate GUID and `[99,1,2,3]` probes + +## Implementation Notes + +Land before reviewable import plan so its identity report is reliable. + + +## Completion Summary + +- Added one shared CrowdAnki identity gateway used by import, export, and round-trip projection +- Defined CrowdAnki GUIDs as opaque exact UTF-8 identifiers; rejected empty and duplicate effective GUIDs with every affected source/note index +- Made missing canonical GUID fallback to stable ID explicit and validated before GUID-assisted suggestion generation +- Enforced zero-based template invariant tmpls[index].ord == index with no sorting, renumbering, or silent repair +- Added malformed ordinal diagnostics for duplicate, gapped, reordered, negative, and overflow values including source paths and expected/found values +- Updated parity comparison so reordered template ordinals are identity defects rather than normalized away +- Added codec and CLI regressions for duplicate GUIDs, opaque lookalikes, and [99,1,2,3] plus other malformed ordinal probes +- Passed full default workspace tests, targeted codec/CLI tests, fmt, clippy, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-cli/tests/cli.rs +- documentation/docs/authoring/importing-crowdanki.md +- documentation/docs/reference/cli.md diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0030-create-a-reviewable-suggested-id-import-plan.md b/.frontloop/_archive/crowdanki-roundtrip/done/0030-create-a-reviewable-suggested-id-import-plan.md new file mode 100644 index 0000000..ec73eae --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0030-create-a-reviewable-suggested-id-import-plan.md @@ -0,0 +1,47 @@ +--- +title: Create a reviewable suggested-ID import plan +priority: high +--- + +## Goal + +Replace blanket `--accept-suggested-ids` with an inspectable plan/override artifact supporting selective approval and repeatable imports. + +## Acceptance Criteria + +- A dry-run emits proposed note/note-type/template IDs with source GUID/model evidence +- Maintainers can override selected suggestions in a documented file +- Import refuses unresolved collisions and unreviewed changes according to policy +- Re-running with the same plan is deterministic +- CLI help/docs cover generate, review, apply, and recovery steps + +## Implementation Notes + +Depends on Unicode-safe suggestion and identity validation. + + +## Completion Summary + +- Replaced blanket --accept-suggested-ids with versioned plan, review, and apply CLI workflow +- Added canonical provenance-bound review-plan schema with source byte fingerprint, import options, source GUID/model/template evidence, proposed IDs, and explicit decisions +- Made plan generation side-effect-free for source decks and apply fail closed on stale source, edited plan evidence, unapproved automatic suggestions, unresolved decisions, and invalid overrides +- Added selective stable-ID overrides with strict syntax, evidence, and global uniqueness validation +- Used existing safe output/workspace transaction and recovery behavior for plan generation and import application +- Removed legacy flag behavior and added actionable migration guidance +- Added deterministic canonical serialization, input-order, stale-plan, collision, override, failure/recovery, format, CLI, and UG-style regressions +- Passed full default tests, focused plan tests, fmt, clippy, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki_import_plan.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/commands/import.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/crowdanki_import_plan_cli.rs +- crates/brain-brew-cli/tests/ug_style_fixture.rs +- documentation/docs/authoring/importing-crowdanki.md +- documentation/docs/concepts/media.md +- documentation/docs/reference/cli.md diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0040-add-media-byte-handoff-and-safe-import-output-transactions.md b/.frontloop/_archive/crowdanki-roundtrip/done/0040-add-media-byte-handoff-and-safe-import-output-transactions.md new file mode 100644 index 0000000..c9b82e2 --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0040-add-media-byte-handoff-and-safe-import-output-transactions.md @@ -0,0 +1,47 @@ +--- +title: Add media-byte handoff and safe import output transactions +priority: high +--- + +## Goal + +Import declared media bytes alongside canonical declarations and prevent accidental overwrite or partial workspace creation. + +## Acceptance Criteria + +- Import inventories CrowdAnki media and verifies every referenced byte +- Generated declarations receive real hashes when bytes are available +- Output defaults to a new clean destination and requires explicit overwrite intent +- Source plus media commit through the workspace transaction module +- Missing/duplicate/unsafe media paths fail before any output changes + +## Implementation Notes + +Depends on source transactions and package-safe media paths. + + +## Completion Summary + +- Added typed CrowdAnki media inventory with JSON source locations and content-reference cross-checking +- Added authorized media-root byte handoff with one-read-per-path provenance evidence and real SHA-256 declarations +- Rejected missing, duplicate, unused, unsafe, case-colliding, and stale media paths/evidence before any output publication +- Published deck source plus all media in a clean staged tree through journaled backup/rollback/recovery transaction semantics +- Made existing output refuse by default and force replacement remove stale media while rejecting symlink and special targets +- Defined explicit reference-only import behavior that makes no byte/hash completeness claim +- Closed duplicate-media plan selection ambiguity through validated evidence/declaration mapping +- Added format, plan, CLI, failure/recovery, unsafe-path, stale, and UG-style fixture regressions +- Passed full tests, focused media tests, fmt, clippy, and Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki_import_plan.rs +- crates/brain-brew-cli/src/commands/import.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/crowdanki_import_plan_cli.rs +- crates/brain-brew-cli/tests/crowdanki_import_media_cli.rs +- crates/brain-brew-cli/tests/ug_style_fixture.rs +- documentation/docs/authoring/importing-crowdanki.md +- documentation/docs/concepts/media.md diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0050-build-a-complete-normalized-crowdanki-equivalence-oracle.md b/.frontloop/_archive/crowdanki-roundtrip/done/0050-build-a-complete-normalized-crowdanki-equivalence-oracle.md new file mode 100644 index 0000000..3a0dacb --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0050-build-a-complete-normalized-crowdanki-equivalence-oracle.md @@ -0,0 +1,40 @@ +--- +title: Build a complete normalized CrowdAnki equivalence oracle +priority: critical +--- + +## Goal + +Compare canonical and CrowdAnki states across every meaningful deck, schema, template, configuration, identity, note, tag, and media property. + +## Acceptance Criteria + +- Oracle builds on complete core semantic diff +- Normalization rules are explicit and narrowly scoped +- Single-property mutation tests prove every supported property is observed +- Unsupported CrowdAnki state remains fail-closed +- Import→export and export→import laws cover Unicode identities and structured media/messages + +## Implementation Notes + +Depends on core semantic-diff completion; required before UG goldens. + + +## Completion Summary + +- Added a typed fail-closed normalized CrowdAnki equivalence oracle built on the compiler-exhaustive core semantic diff +- Defined six explicit symmetric round-trip loss dimensions and prohibited normalization of retained semantics +- Added a property-mutation matrix covering every CrowdAnki-representable property that survives projection +- Rejected unknown and non-default unsupported CrowdAnki state with typed category and canonical/CrowdAnki source paths +- Added import/export law coverage for NFC/CJK/RTL identities, GUID versus stable-ID behavior, structured messages, images, and declared media +- Made media equivalence proof explicit: byte handoff verifies hashes/bytes; reference-only returns NotProven and cannot prove media identity +- Documented oracle scope, round-trip losses, unsupported state, and release-proof requirements +- Passed focused/default tests, fmt, clippy, docs, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- documentation/docs/reference/crowdanki-equivalence-oracle.md +- documentation/docs/reference/release-oracle.md +- documentation/sidebars.js diff --git a/.frontloop/_archive/crowdanki-roundtrip/done/0060-implement-or-formally-deprecate-existing-source-reconciliation.md b/.frontloop/_archive/crowdanki-roundtrip/done/0060-implement-or-formally-deprecate-existing-source-reconciliation.md new file mode 100644 index 0000000..0f90512 --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/done/0060-implement-or-formally-deprecate-existing-source-reconciliation.md @@ -0,0 +1,60 @@ +--- +title: Implement or formally deprecate existing-source reconciliation +priority: medium +--- + +## Goal + +Resolve `default/clarify/7500` and either build a reviewed Anki-to-existing-federated-source merge workflow or document why import remains bootstrap-only and provide replacement guidance. + +## Acceptance Criteria + +- The default/7500 decision is explicitly resolved +- If implemented, changes are mapped to canonical base versus overlay ownership with conflict review and no silent rewrites +- If deprecated, CLI/docs remove round-trip implications and describe supported export/bootstrap boundaries +- UG migration documentation reflects the decision +- End-to-end tests cover the chosen workflow + +## Implementation Notes + +Final CrowdAnki product task; do not start before default/7500 clarification and safe mutation/equivalence work. + + +## Completion Summary + +- Formally deferred existing-source Anki/CrowdAnki reconciliation pending demonstrated maintainer demand +- Defined CrowdAnki import as plan/review/apply full-deck bootstrap output only, never a merge into an existing federated source or overlay stack +- Removed user-facing round-trip/pull/reconciliation promises while retaining adapter-equivalence implementation terminology +- Documented safe boundaries for export, bootstrap import, plan/apply, and diff --as-overlay review artifacts including include/structured/translation/media lossiness +- Documented that UG workflow policy is externally owned and Brain Brew does not restore legacy Anki-to-source support +- Added end-to-end CLI contracts proving bootstrap import refuses existing output without explicit force, preserves it on refusal, rejects pull/reconcile flags, and does not advertise a reconciliation workflow +- Passed full tests, focused CLI contracts, fmt, clippy, docs, release smoke, and Claude judgment + +### Files Changed + +- AGENTS.md +- CONTEXT.md +- README.md +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/output_transaction.rs +- crates/brain-brew-cli/tests/crowdanki_import_plan_cli.rs +- documentation/docs/authoring/crowdanki-bootstrap-boundary.md +- documentation/docs/authoring/diff-explain.md +- documentation/docs/authoring/importing-crowdanki.md +- documentation/docs/authoring/media.md +- documentation/docs/concepts/canonical-deck.md +- documentation/docs/concepts/identity.md +- documentation/docs/concepts/media.md +- documentation/docs/concepts/what-is-federation.md +- documentation/docs/examples/ultimate-geography.md +- documentation/docs/intro.md +- documentation/docs/reference/cli.md +- documentation/docs/reference/crowdanki-equivalence-oracle.md +- documentation/docs/reference/decisions/0001-scope-brain-brew-as-local-first-deck-federation.md +- documentation/docs/reference/decisions/0003-use-canonical-deck-as-federation-format.md +- documentation/docs/reference/decisions/0005-store-maintainer-source-as-strict-canonical-yaml.md +- documentation/docs/reference/glossary.md +- documentation/docs/reference/project-scope.md +- documentation/docs/reference/workspace-transactions.md +- documentation/docs/reference/yaml.md +- documentation/sidebars.js diff --git a/.frontloop/_archive/crowdanki-roundtrip/epic.md b/.frontloop/_archive/crowdanki-roundtrip/epic.md new file mode 100644 index 0000000..05482b1 --- /dev/null +++ b/.frontloop/_archive/crowdanki-roundtrip/epic.md @@ -0,0 +1,15 @@ +--- +title: CrowdAnki identity and round-trip workflows +slug: crowdanki-roundtrip +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Make CrowdAnki import/export identity-safe, Unicode-safe, reviewable, and transaction-safe, with a complete semantic oracle. Extend it toward reconciliation with existing federated source only after the existing default/7500 product decision is resolved. + +## Sequence + +Repair imported identities and ordinals first; add a reviewable import plan and safe media/output handoff; complete the equivalence oracle; then implement or formally reject existing-source reconciliation. diff --git a/.frontloop/_archive/deferred/7500-explore-crowdanki-round-trip-import-workflow.md b/.frontloop/_archive/deferred/7500-explore-crowdanki-round-trip-import-workflow.md new file mode 100644 index 0000000..d7a4aca --- /dev/null +++ b/.frontloop/_archive/deferred/7500-explore-crowdanki-round-trip-import-workflow.md @@ -0,0 +1,37 @@ +--- +title: Explore a CrowdAnki round-trip import workflow (pull in-Anki edits back to source) +priority: low +--- + +## Goal + +A maintainer who edits notes directly in Anki can bring those edits home: export via CrowdAnki, run one supported Brain Brew workflow (ideally a single command), and get a reviewable change against the canonical source — restoring, in overlay-native form, what the old Python world's `anki_to_source.yaml` recipe provided. + +## Status: clarify — deliberately deferred + +The loss of the old Anki→source recipe was a **purposeful simplification** during the UG migration, not an oversight. This is a later thing. Do not promote to ready until the core review backlog (media integrity, include preservation, stale resolution, resolver unification) has landed and there is actual maintainer demand for the round trip. + +## Problem + +The primitives exist but are dead-ended: + +- `brainbrew import crowdanki --accept-suggested-ids --out deck.yaml` (`crates/brain-brew-cli/src/commands/import.rs` ~:9) does a full re-import into a fresh deck file — right for initial migration, destructive for round-tripping (would flatten includes, structured messages, media declarations, overlay federation). +- `brainbrew diff --as-overlay` (`crates/brain-brew-cli/src/commands/diff.rs` ~:11) drafts an overlay from a semantic diff — the correct primitive for expressing "what changed in Anki" against a composed target. +- Nothing connects them; UG's CONTRIBUTING documents CrowdAnki in one direction only (smoke-test import into Anki, ~:494). + +The plausible workflow: import the Anki export to a temp canonical deck → diff against the composed English target → emit a reviewable overlay/patch draft → human reviews and applies to source. + +## Questions to settle before ready + +1. **ID alignment.** Import derives suggested StableIds from names/first field. When do they line up with the existing deck's IDs, and what does the workflow do when they don't? (Interacts with the derived-ID-collision fail-closed fix.) +2. **Reverse-mapping lossiness.** The diff runs against the composed target, where includes are materialized and structured messages are rendered. Edits landing in those fields can be detected but not automatically written back to source form. Is "reviewable draft + human step" the contract, or does any subset (plain scalar fields) get an automatic write-back path? (`!image` adoption would shrink this gap for image fields.) +3. **Command shape.** A documented three-step recipe using existing commands vs a convenience command (`brainbrew pull`-style) wrapping import→compose→diff. What does the output artifact look like — overlay draft on stdout, a file, a workbench surface? +4. **Scope of targets.** Old world was English-only. Is the round trip defined only against the base/EN composed target, or per-target (which would imply reverse-mapping through translation dictionaries — much harder, likely out of scope)? +5. **Demand check.** Confirm a real UG maintainer scenario before building; if nobody edits in Anki anymore, close this instead. + +## Acceptance Criteria (draft, to firm up on promotion) + +- A supported, documented, tested workflow (command or recipe) producing a reviewable source-level change from a CrowdAnki export of an existing deck. +- Non-destructive by construction: never overwrites `deck.yaml`/overlays directly; output is a draft a human applies. +- Honest about lossiness: structured-message and include-backed fields are flagged as needing manual handling, not silently mangled. +- UG-side follow-up task (CONTRIBUTING maintainer section) filed once the tool side ships. diff --git a/.frontloop/_archive/package-federation-security/done/0005-choose-fetched-package-symlink-policy.md b/.frontloop/_archive/package-federation-security/done/0005-choose-fetched-package-symlink-policy.md new file mode 100644 index 0000000..1eb046f --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0005-choose-fetched-package-symlink-policy.md @@ -0,0 +1,29 @@ +--- +title: Choose fetched-package symlink policy +priority: critical +--- + +## Goal + +Decide whether fetched package snapshots reject all symlinks or permit only links canonically contained within the authenticated root. + +## Acceptance Criteria + +- Select the policy for path, git, and tarball sources +- Define behavior for links to files, directories, broken links, and cycles +- Specify canonicalization timing and diagnostics +- Document compatibility impact for existing packages + +## Implementation Notes + +Blocks snapshot/extraction hardening. + +## Questions + +### Q1: Recommended: reject symlinks in fetched/locked packages for the next preview; reconsider contained links only with demonstrated consumer need. Approve or choose contained-link support. + +## Decision + +Adopt **reject all symlinks** for path, Git, tarball, staging, and cached package trees in the next preview. This is the safest auditable policy and is reversible only through a future explicit format/policy decision backed by a demonstrated consumer need. Links to files or directories, broken links, and link cycles are all rejected from entry metadata before traversal or extraction. Diagnostics identify the source tree and offending entry. The incompatible behavior and migration guidance are documented in the package-locking and lockfile references. + +Agent Tick was unavailable during execution, so the orchestrator applied the task's conservative recommended option under the user's instruction to execute the full remediation program without stopping. diff --git a/.frontloop/_archive/package-federation-security/done/0006-choose-package-compatibility-version-semantics.md b/.frontloop/_archive/package-federation-security/done/0006-choose-package-compatibility-version-semantics.md new file mode 100644 index 0000000..657a8b6 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0006-choose-package-compatibility-version-semantics.md @@ -0,0 +1,29 @@ +--- +title: Choose package compatibility version semantics +priority: high +--- + +## Goal + +Decide whether package compatibility uses exact versions, real semantic ranges, or removes the currently inert field until implemented. + +## Acceptance Criteria + +- Select exact, semver-range, or temporary removal semantics +- Define validation for base and extension package relationships +- Specify manifest migration and diagnostics +- Add examples covering compatible and incompatible package sets + +## Implementation Notes + +Blocks compatibility enforcement but not path/hash hardening. + +## Questions + +### Q1: Recommended: implement real semver ranges if federation is promoted from experimental; otherwise remove `compatible_base_versions` until promotion. Which posture should this preview take? + +## Decision + +Implement real SemVer compatibility for the hardened federation preview. Dependencies identify an exact package version; an extension declares `base_package` plus an OR-list of canonical SemVer requirements, with comma-separated comparators interpreted as AND and prereleases following the `semver` crate. Compatibility validates the exact selected/locked dependency; Brain Brew does not solve versions. Invalid/noncanonical versions or requirements fail closed with manifest context. ADR-0017 and the schema references record the migration. + +Agent Tick was unavailable during execution, so the orchestrator applied the real-SemVer option while carrying out the user's instruction to complete the full federation hardening program. diff --git a/.frontloop/_archive/package-federation-security/done/0007-choose-release-media-verification-policy.md b/.frontloop/_archive/package-federation-security/done/0007-choose-release-media-verification-policy.md new file mode 100644 index 0000000..8e0d1be --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0007-choose-release-media-verification-policy.md @@ -0,0 +1,29 @@ +--- +title: Choose release media verification policy +priority: critical +--- + +## Goal + +Decide whether any declared media makes a byte root mandatory for release verify/export and how manifests configure package-specific roots. + +## Acceptance Criteria + +- Define development versus release verification modes +- Specify when empty or malformed hashes are permitted +- Define manifest/CLI behavior for package-specific media roots +- Document export behavior when no byte root is available + +## Implementation Notes + +Blocks final media-integrity enforcement and UG release gate. + +## Questions + +### Q1: Recommended: allow reference-only checks as an explicit development mode, but require package-owned media roots, non-empty valid hashes, and byte validation for release verify/export. Approve this policy? + +## Decision + +Adopt strict-by-default media release verification. Any media-bearing verify or manifest export requires explicit owner roots, canonical non-empty SHA-256 hashes, present matching bytes, and complete reference/collision validation. `--media-mode reference-only` is an explicit development/structural mode that still validates references but reports `NOT RELEASE-READY` and `release_ready: false`; a missing root never downgrades implicitly. Package-qualified roots use the ownership mapping introduced by task 0070. Hashless fixtures invoke reference-only explicitly and are documented as structural evidence only. + +Agent Tick was unavailable during execution, so the orchestrator applied the task's conservative recommended policy while fulfilling the user's full remediation instruction. diff --git a/.frontloop/_archive/package-federation-security/done/0010-introduce-one-canonical-safe-relative-path-authorization-module.md b/.frontloop/_archive/package-federation-security/done/0010-introduce-one-canonical-safe-relative-path-authorization-module.md new file mode 100644 index 0000000..c37035f --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0010-introduce-one-canonical-safe-relative-path-authorization-module.md @@ -0,0 +1,60 @@ +--- +title: Introduce one canonical safe-relative-path authorization module +priority: critical +--- + +## Goal + +Reject absolute, parent, and canonical-root escapes consistently for package manifests, bases, overlays, includes, media, and writes. + +## Acceptance Criteria + +- All filesystem paths crossing a package/workspace root use one typed authorization interface +- Absolute and `..` paths fail before I/O +- Canonical containment is checked after existing parent/link resolution according to policy +- Diagnostics name the declaring file and offending field +- Adversarial tests cover every source route and platform-relevant path form + +## Implementation Notes + +First implementation task; CLI owns filesystem authorization while formats retains portable path syntax validation. + + +## Completion Summary + +- Added portable SafeRelativePath syntax validation in formats and one CLI PathAuthorizer with typed declaration/field/value/root diagnostics +- Rejected empty, absolute, dot/parent, drive, UNC, backslash, ambiguous separator, and NUL paths before target I/O +- Added canonical existing-target and deepest-existing-parent containment for read/create authorization, including symlink/non-directory escape rejection +- Migrated package base/overlay, scalar/media includes, locked manifests, media reads/hash/copy, export/golden, Workbench source/media/new-language, and transaction target paths +- Removed implicit ancestor Workbench media discovery and documented incompatible parent-path behavior +- Added adversarial route and platform-form tests while retaining valid in-root fixtures +- Passed full fmt/test/clippy, 13 E2E, release smoke, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-formats/src/safe_relative_path.rs +- crates/brain-brew-formats/src/lib.rs +- crates/brain-brew-formats/src/source_includes.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/canonical_source_document.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/tests/safe_relative_path.rs +- crates/brain-brew-cli/src/path_authorization.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/io.rs +- crates/brain-brew-cli/src/media_assets.rs +- crates/brain-brew-cli/src/workspace_transaction.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/src/commands/media.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/safe_paths.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs +- documentation/docs/reference/yaml.md +- documentation/docs/reference/lockfile.md +- documentation/docs/reference/workbench.md +- documentation/docs/authoring/manifests-targets.md +- documentation/docs/authoring/media.md +- documentation/docs/authoring/workspace.md diff --git a/.frontloop/_archive/package-federation-security/done/0020-require-typed-source-specific-immutable-lock-entries.md b/.frontloop/_archive/package-federation-security/done/0020-require-typed-source-specific-immutable-lock-entries.md new file mode 100644 index 0000000..bdb58a5 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0020-require-typed-source-specific-immutable-lock-entries.md @@ -0,0 +1,48 @@ +--- +title: Require typed source-specific immutable lock entries +priority: critical +--- + +## Goal + +Make lock verification authenticate the exact source tree by requiring canonical SRI hashes and only fields valid for each source kind. + +## Acceptance Criteria + +- Path, git/GitHub, and tarball locks use explicit source-specific schemas +- A valid canonical SHA-256/SRI hash is mandatory for immutable verification +- Hashless or manually weakened locks fail closed +- Lock update emits deterministic canonical entries and migration guidance +- Warm-cache verification rehashes content and rejects drift + +## Implementation Notes + +Depends on safe path authorization; retain current good cache rehash behavior. + + +## Completion Summary + +- Introduced lock schema v2 with source-tagged path, Git, and tarball original/locked variants and source-inapplicable field rejection +- Required canonical SHA-256 SRI NAR hashes and immutable Git commit identities for every verified package +- Added fail-closed v1 migration diagnostics and deterministic canonical lock update output +- Rehashed live path sources on every verify and authenticated cached trees before use, rejecting warm-cache tampering +- Added malformed SRI, field-smuggling, weakened-lock, relocation, source/cache drift, and byte-idempotence regressions +- Updated lock/package/YAML documentation and fixtures +- Passed full fmt/test/clippy, focused codec/CLI tests, release smoke, and independent Claude judgment + +### Files Changed + +- Cargo.lock +- crates/brain-brew-formats/Cargo.toml +- crates/brain-brew-formats/src/lockfile.rs +- crates/brain-brew-formats/tests/lockfile_yaml.rs +- crates/brain-brew-formats/tests/emitter_roundtrip.rs +- crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs +- crates/brain-brew-formats/tests/yaml_schema_strictness.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/tests/lock_cli.rs +- crates/brain-brew-cli/tests/cli.rs +- documentation/docs/authoring/packages-locking.md +- documentation/docs/reference/lockfile.md +- documentation/docs/reference/yaml.md diff --git a/.frontloop/_archive/package-federation-security/done/0030-harden-snapshots-and-archive-extraction-against-escapes.md b/.frontloop/_archive/package-federation-security/done/0030-harden-snapshots-and-archive-extraction-against-escapes.md new file mode 100644 index 0000000..53b0e82 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0030-harden-snapshots-and-archive-extraction-against-escapes.md @@ -0,0 +1,42 @@ +--- +title: Harden snapshots and archive extraction against escapes +priority: critical +--- + +## Goal + +Ensure copied, cloned, and extracted packages cannot retain symlink, special-file, traversal, or root-escape content outside the hashed tree. + +## Acceptance Criteria + +- The approved symlink policy is enforced for all source kinds +- Archive traversal, absolute paths, hard links, devices, and special files are rejected +- The hashed tree is exactly the tree later used by planning +- Existing out-of-tree manifest/symlink reproductions fail +- Cache writes remain content-addressed and recover safely after rejection + +## Implementation Notes + +Depends on safe paths and symlink-policy decision. + + +## Completion Summary + +- Adopted and documented a reject-all-symlinks policy for path, Git, tarball, staging, and cached package trees +- Added centralized package-tree validation/copy/extraction with no-follow/create-new semantics, normalized permissions, and special-file rejection +- Rejected traversal/platform paths, symlink/hard-link/device/FIFO/socket/sparse/unknown entries, PAX sparse/path tricks, and duplicate/colliding targets before writes +- Validated trees before and after hashing and consumed exactly the validated content-addressed tree +- Added locked same-filesystem atomic cache publication, failure cleanup, and warm-cache policy/hash revalidation +- Added adversarial archive/source/cache tests and preserved valid deterministic package workflows +- Passed full fmt/test/clippy, E2E, release smoke, and independent Claude judgment + +### Files Changed + +- Cargo.lock +- crates/brain-brew-cli/Cargo.toml +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/package_tree.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/tests/lock_cli.rs +- documentation/docs/authoring/packages-locking.md +- documentation/docs/reference/lockfile.md diff --git a/.frontloop/_archive/package-federation-security/done/0040-add-bounded-secure-network-fetch-policy.md b/.frontloop/_archive/package-federation-security/done/0040-add-bounded-secure-network-fetch-policy.md new file mode 100644 index 0000000..341e35d --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0040-add-bounded-secure-network-fetch-policy.md @@ -0,0 +1,44 @@ +--- +title: Add bounded secure network fetch policy +priority: high +--- + +## Goal + +Constrain lock updates and fetches by transport, redirect, timeout, byte, and decompression budgets. + +## Acceptance Criteria + +- HTTPS is required except explicit local/test adapters +- Connect/read/total timeouts are configured and tested +- Redirect count and cross-scheme behavior are bounded +- Download and extracted-byte/file-count/ratio limits prevent archive bombs +- Failures clean temporary state and report the exhausted budget + +## Implementation Notes + +Implement after source-specific lock schemas define fetch adapters. + + +## Completion Summary + +- Added one injectable FetchPolicy used by GitHub API, codeload, and tarball package sources +- Required HTTPS in production, disabled automatic redirects, bounded manual redirects, rejected downgrade/credentials/unsupported schemes, and stripped auth across hosts +- Added connect/read/total monotonic deadlines and bounded streaming temporary files with Content-Length and chunked overflow enforcement +- Added compressed, JSON, decompressed tar, per-file, entry-count, expanded-byte, path, metadata, and expansion-ratio limits +- Added structured source/budget/current/limit failures with temporary cleanup and valid-cache preservation +- Added deterministic local transport, redirect, timeout, body framing, archive bomb, truncation, and cleanup tests and documented defaults/rationale +- Passed full fmt/test/clippy, E2E, release smoke, focused lock/fetch tests, and independent Claude judgment + +### Files Changed + +- Cargo.lock +- crates/brain-brew-cli/Cargo.toml +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/fetch_policy.rs +- crates/brain-brew-cli/src/package_tree.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-formats/src/lockfile.rs +- crates/brain-brew-formats/tests/lockfile_yaml.rs +- documentation/docs/authoring/packages-locking.md +- documentation/docs/reference/lockfile.md diff --git a/.frontloop/_archive/package-federation-security/done/0050-consolidate-all-manifest-operations-behind-one-registry-aware-planner.md b/.frontloop/_archive/package-federation-security/done/0050-consolidate-all-manifest-operations-behind-one-registry-aware-planner.md new file mode 100644 index 0000000..1011bd5 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0050-consolidate-all-manifest-operations-behind-one-registry-aware-planner.md @@ -0,0 +1,53 @@ +--- +title: Consolidate all manifest operations behind one registry-aware planner +priority: critical +--- + +## Goal + +Use one provenance-retaining planner for compose, verify, explain, targets text/JSON, translations, Workbench, explicit includes, package roots, and sibling locks. + +## Acceptance Criteria + +- Every command route applies identical package discovery, dependency, cycle, and target expansion rules +- Package-qualified targets work in text and JSON modes +- Explicit `--include` cannot bypass dependency validation +- Plans retain package/source ownership for each base, overlay, include, and media declaration +- The local-only expansion path is removed or restricted to a clearly tested private use + +## Implementation Notes + +After path/lock invariants; preserve deterministic order. + + +## Completion Summary + +- Added one CLI-owned ManifestRegistry and TargetPlan planner with typed package/source/target/base/overlay/include/media provenance +- Unified root, explicit include, package-root, and sibling-lock discovery with deterministic precedence and duplicate identity rejection +- Applied identical dependency/version/cycle, qualified target lookup, target extends, and overlay expansion semantics across all manifest-backed commands +- Migrated compose, validate, verify, explain, export, targets text/JSON, translations, media, and Workbench selection/cache/media discovery +- Closed explicit --include dependency bypass and made unqualified ambiguity fail closed +- Removed CLI callers of formats-local target expansion and retained transitive source hashes/ownership for later policy enforcement +- Passed broad planner adversarial tests, full fmt/test/clippy, 13 E2E, 74+26 fixture verification, release smoke, and Claude judgment + +### Files Changed + +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/io.rs +- crates/brain-brew-cli/src/package_resolver.rs +- crates/brain-brew-cli/src/path_authorization.rs +- crates/brain-brew-cli/src/commands/compose.rs +- crates/brain-brew-cli/src/commands/validate.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/explain.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/targets.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/media.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/registry_planner.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/canonical_source_document.rs +- crates/brain-brew-formats/src/overlay_source_document.rs diff --git a/.frontloop/_archive/package-federation-security/done/0060-validate-package-graph-catalogs-identities-and-compatibility.md b/.frontloop/_archive/package-federation-security/done/0060-validate-package-graph-catalogs-identities-and-compatibility.md new file mode 100644 index 0000000..a797ab9 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0060-validate-package-graph-catalogs-identities-and-compatibility.md @@ -0,0 +1,61 @@ +--- +title: Validate package graph catalogs identities and compatibility +priority: high +--- + +## Goal + +Fail closed on package cycles, missing dependencies, incompatible base versions, and catalog entries whose declared ID/kind differs from loaded overlays. + +## Acceptance Criteria + +- Package dependency cycles are detected with a trace +- Missing dependencies fail through every discovery route +- Approved compatibility semantics are enforced +- Overlay catalog ID and kind match decoded overlay content +- Regression fixtures cover package-qualified overlays and duplicate package identities + +## Implementation Notes + +Depends on consolidated planning and compatibility decision. + + +## Completion Summary + +- Required canonical exact SemVer dependency identities and explicit extension base_package compatibility requirements +- Implemented OR-list/AND-comparator SemVer range enforcement with documented prerelease behavior and no implicit version solving +- Validated complete package registries for missing, duplicate/conflicting, incompatible, self-cycle, and multi-package graphs with deterministic edge traces +- Decoded every catalog overlay and required catalog key/kind to match actual overlay ID/kind, rejecting aliases and qualified ownership mismatches +- Migrated fixtures and added ADR-0017, changelog, authoring/reference, lock, and downstream-package documentation +- Added discovery-route, package-qualified, cycle, identity, version/range/prerelease, and catalog mismatch regressions +- Passed full fmt/test/clippy, 13 E2E, release/docs builds, 74+26 fixture verification, and Claude judgment + +### Files Changed + +- CHANGELOG.md +- Cargo.toml +- Cargo.lock +- crates/brain-brew-formats/Cargo.toml +- crates/brain-brew-formats/src/package_semver.rs +- crates/brain-brew-formats/src/manifest.rs +- crates/brain-brew-formats/src/lockfile.rs +- crates/brain-brew-formats/src/lib.rs +- crates/brain-brew-formats/tests/manifest_yaml.rs +- crates/brain-brew-formats/tests/lockfile_yaml.rs +- crates/brain-brew-formats/tests/emitter_roundtrip.rs +- crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs +- crates/brain-brew-formats/tests/yaml_schema_strictness.rs +- crates/brain-brew-cli/src/package_resolver.rs +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/src/output.rs +- crates/brain-brew-cli/tests/registry_planner.rs +- crates/brain-brew-cli/tests/cli.rs +- fixtures/ug-style/brainbrew.yaml +- fixtures/ultimate-geography/brainbrew.yaml +- documentation/docs/reference/decisions/0017-enforce-semantic-version-package-compatibility.md +- documentation/docs/reference/decisions/README.md +- documentation/docs/authoring/manifests-targets.md +- documentation/docs/authoring/packages-locking.md +- documentation/docs/examples/downstream-package.md +- documentation/docs/reference/lockfile.md +- documentation/docs/reference/yaml.md diff --git a/.frontloop/_archive/package-federation-security/done/0070-track-federated-media-ownership-and-protect-dependency-sources.md b/.frontloop/_archive/package-federation-security/done/0070-track-federated-media-ownership-and-protect-dependency-sources.md new file mode 100644 index 0000000..75e7225 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0070-track-federated-media-ownership-and-protect-dependency-sources.md @@ -0,0 +1,51 @@ +--- +title: Track federated media ownership and protect dependency sources +priority: critical +--- + +## Goal + +Carry package-specific media-root provenance through planning, verification, export, and mutation so root commands cannot rewrite locked dependency caches. + +## Acceptance Criteria + +- Every media declaration/reference is attributable to a package and authorized media root +- Verification resolves bytes against the owning package root +- Mutation commands only propose root-workspace files unless an explicit supported vendor workflow exists +- Locked/cache sources are read-only and integrity-checked after operations +- Cross-package media collision and missing-root tests are deterministic + +## Implementation Notes + +Depends on provenance-aware planner and safe mutation modules. + + +## Completion Summary + +- Added final per-target media ownership plans by replaying ordered overlay declarations and preserving package/source/root/document provenance +- Bound every media ID/path reference to its final declaration owner and rejected cross-package ID/path/output ambiguity +- Added repeatable package-qualified media-root mappings while retaining unqualified root-package-only compatibility +- Migrated manifest verify, export, and Workbench media reads/copies to owner-authorized roots with no target-root fallback +- Guarded media hash/images-to-refs to root-workspace-owned sources/declarations before writes and moved them onto typed source documents and recoverable transactions +- Validated locked package trees before and after mutation without cache repair and added collision/root/ownership/integrity regressions +- Passed full fmt/test/clippy, 13 E2E, 74+26 fixture verification, release smoke, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-cli/src/media_ownership.rs +- crates/brain-brew-cli/src/media_assets.rs +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/src/io.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/args.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/commands/media.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/src/commands/lock.rs +- crates/brain-brew-cli/tests/federated_media_ownership.rs +- documentation/docs/authoring/media.md +- documentation/docs/authoring/packages-locking.md +- documentation/docs/authoring/verify-export.md +- documentation/docs/reference/workbench.md diff --git a/.frontloop/_archive/package-federation-security/done/0080-enforce-release-media-integrity-and-safe-rendered-paths.md b/.frontloop/_archive/package-federation-security/done/0080-enforce-release-media-integrity-and-safe-rendered-paths.md new file mode 100644 index 0000000..86f6dc6 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0080-enforce-release-media-integrity-and-safe-rendered-paths.md @@ -0,0 +1,65 @@ +--- +title: Enforce release media integrity and safe rendered paths +priority: critical +--- + +## Goal + +Apply the approved media policy consistently to verify/export and reject unsafe or HTML-breaking media paths before rendering or copying. + +## Acceptance Criteria + +- Release verify/export require owned media roots, valid non-empty hashes, and byte validation when media is declared +- Development reference-only mode is explicit and cannot be mistaken for release readiness +- Export validates references even when byte copying is disabled +- Media paths are canonical, symlink-safe, and safely encoded or rejected for HTML attributes +- Dirty/stale output cannot survive failed export + +## Implementation Notes + +Depends on media-policy decision, ownership planning, and clean output transactions. + + +## Completion Summary + +- Made media-bearing verify and manifest export strict by default with owner roots, canonical non-empty SHA-256, present bytes, and streamed hash comparison +- Added explicit --media-mode reference-only that retains semantic reference/collision checks and reports NOT RELEASE-READY/release_ready false +- Kept export clean-tree transactional publication and validated references even when bytes are intentionally omitted +- Added portable hostile media path restrictions, UTF-8 URL encoding, HTML attribute escaping, and scanner entity/percent normalization +- Added strict/reference-only, owner/root/hash/byte/mismatch/collision/hostile-render/output preservation regressions +- Marked all hashless UG fixture helpers and release smoke as explicit structural-only reference mode with drift tests +- Updated changelog, CLI/media/release/UG documentation and scripts +- Passed full CPU-bounded tests, clippy, 13 E2E, docs, release smoke, focused media tests, and Claude re-judgment + +### Files Changed + +- .sd/tasks.yaml +- CHANGELOG.md +- crates/brain-brew-cli/src/media_verification.rs +- crates/brain-brew-cli/src/media_assets.rs +- crates/brain-brew-cli/src/args.rs +- crates/brain-brew-cli/src/main.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/tests/release_media_integrity.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-cli/tests/cli_contract.rs +- crates/brain-brew-cli/tests/ug_style_fixture.rs +- crates/brain-brew-core/src/compose.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-formats/src/safe_relative_path.rs +- crates/brain-brew-formats/src/media.rs +- crates/brain-brew-formats/src/crowdanki.rs +- crates/brain-brew-formats/tests/safe_relative_path.rs +- crates/brain-brew-formats/tests/media_references.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- documentation/docs/authoring/media.md +- documentation/docs/authoring/verify-export.md +- documentation/docs/concepts/media.md +- documentation/docs/examples/ultimate-geography.md +- documentation/docs/reference/cli.md +- documentation/docs/reference/releasing.md +- scripts/release_smoke.sh +- scripts/sync-ug-fixture.sh +- scripts/ug-fixture-sync/README.md diff --git a/.frontloop/_archive/package-federation-security/done/0090-bound-and-prune-recursive-package-discovery.md b/.frontloop/_archive/package-federation-security/done/0090-bound-and-prune-recursive-package-discovery.md new file mode 100644 index 0000000..ddc5a8e --- /dev/null +++ b/.frontloop/_archive/package-federation-security/done/0090-bound-and-prune-recursive-package-discovery.md @@ -0,0 +1,51 @@ +--- +title: Bound and prune recursive package discovery +priority: medium +--- + +## Goal + +Prevent irrelevant trees and symlink cycles from making package-root discovery slow, nondeterministic, or unsafe. + +## Acceptance Criteria + +- Discovery prunes `.git`, `.jj`, target, generated output, cache, and configured ignore directories +- Symlink cycles cannot recurse indefinitely +- Maximum depth/file budgets are configurable with actionable errors +- Traversal order is deterministic +- Large-tree benchmarks and fixtures cover pruning and duplicate manifests + +## Implementation Notes + +Last federation scalability task after path semantics are centralized. + + +## Completion Summary + +- Added one registry-owned DiscoveryPolicy/Result/Stats/Error path for every package-root route and translation suggestions +- Added deterministic sorted no-follow traversal with root/component symlink, special entry, permission, replacement, and directory identity checks +- Pruned exact VCS/devenv/target/build/output/dist/site/node_modules/docs/cache/transaction/Nix-result names before descent +- Added repeatable SafeRelativePath-authorized component glob ignores with documented *, ?, and ** semantics +- Added validated configurable depth/entry/manifest defaults and hard maxima with actionable budget diagnostics +- Exposed discovery stats in targets JSON and added pruning/symlink/budget/duplicate/override/route/moderate-count regressions +- Passed CPU-bounded full CI, focused discovery/planner/path tests, E2E rerun, docs, release smoke, and Claude judgment + +### Files Changed + +- CHANGELOG.md +- crates/brain-brew-cli/src/package_resolver.rs +- crates/brain-brew-cli/src/planner.rs +- crates/brain-brew-cli/src/args.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/src/commands/compose.rs +- crates/brain-brew-cli/src/commands/validate.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/src/commands/explain.rs +- crates/brain-brew-cli/src/commands/export.rs +- crates/brain-brew-cli/src/commands/targets.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/media.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/package_discovery.rs +- documentation/docs/authoring/packages-locking.md +- documentation/docs/reference/cli.md diff --git a/.frontloop/_archive/package-federation-security/epic.md b/.frontloop/_archive/package-federation-security/epic.md new file mode 100644 index 0000000..af66c33 --- /dev/null +++ b/.frontloop/_archive/package-federation-security/epic.md @@ -0,0 +1,15 @@ +--- +title: Package federation trust and provenance +slug: package-federation-security +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Ensure every package, manifest, include, overlay, media file, and lock entry is contained, authenticated, provenance-aware, and processed within bounded resource budgets. Unify package planning so all CLI and Workbench routes enforce the same dependency rules. + +## Sequence + +Choose symlink and compatibility policy, close path/hash escapes, add bounded fetching, consolidate planning and validation, then add media ownership and scalable discovery. diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md b/.frontloop/_archive/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md new file mode 100644 index 0000000..6abd2a1 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md @@ -0,0 +1,49 @@ +--- +title: Runtime swap — remove Iced, boot the existing DOM layer from Leptos +priority: high +--- + +## Goal + +Remove Iced entirely from `crates/brain-brew-workbench-ui` and replace it with a minimal Leptos (CSR) bootstrap, keeping the existing imperative DOM layer (`publish_*` functions, event `Closure`s, localStorage helpers, generation counters) byte-for-byte functional. Runtime swap, not a rewrite: after this stage the app behaves identically and all 13 E2E scenarios stay green. **This is the de-risk milestone — delegate first, judge, and integrate before any later stage.** + +## Background (verified) + +`run()` (lib.rs:29) starts `iced::application(WorkbenchApp::new, update, view)`. The Iced layer does only this: on start, `Task::perform(fetch_workspace())` + `Task::perform(fetch_note_pivot())`; on completion, `publish_workspace_probe("loaded", …)` / `publish_note_pivot_panel(&pivot)` (or error variants; `NotePivotLoaded(Err)` with `is_stale_workbench_request` is swallowed). `Message::RefreshWorkspace` is never dispatched from the DOM — all interactivity is self-contained `web_sys`. The Iced `view()` renders a decorative canvas shell no E2E test observes; it may disappear. + +## Work items + +1. `Cargo.toml`: delete both `iced` deps (wasm32 + non-wasm32 sections). Add `leptos` 0.8.x `features = ["csr"]` (pin the current 0.8 patch at impl time) under the wasm32 target (or unconditional if it builds natively cleanly — native `devenv shell test` build must stay green). Keep `gloo-net`, `wasm-bindgen`, `wasm-bindgen-futures`, `web-sys`, `serde_json`. Update crate `description` (currently "Iced/WASM frontend") to Leptos. +2. `src/main.rs`: replace `fn main() -> iced::Result {…}` with a Leptos CSR entry: on wasm32, `leptos::mount::mount_to_body(App)` (+ optional console_error_panic_hook); on non-wasm32, `fn main() {}` so `--all-targets` builds. +3. `src/lib.rs`: delete `run()`, `theme()`, `WorkbenchApp`, `Message`, the Iced `view()`, all `iced::` imports. Add a minimal wasm32-gated `App` component that renders nothing visible of its own and, on mount, does exactly what `WorkbenchApp::new` + `update` did (set probe loading; fetch `/api/workspace` → `publish_workspace_probe`; fetch note pivot → `publish_note_pivot_panel` / swallow stale / `publish_note_pivot_error`). Preserve exact status strings — probe text is asserted. Keep `WorkspaceSummary`/`from_workspace_json` public and unchanged (`tests/workspace_summary.rs` must stay green unmodified). +4. Do NOT restructure/rename/"improve" any `publish_*` fn, event wiring, generation counter, or localStorage helper this stage. Mechanical deletion of dead Iced-only code is fine; behavior changes are not. +5. Do not modify `index.html`. `mount_to_body` appends after the static `#brainbrew-workbench-e2e` div; probe updates continue via the existing `publish_workspace_probe` path. +6. Regenerate embedded release assets: `devenv shell workbench-ui-embed`, include the refreshed `crates/brain-brew-cli/assets/workbench` in the payload (the one sanctioned write outside the UI crate — purges Iced/wgpu from shipped assets). + +## Acceptance Criteria + +- `rg -i "iced|wgpu|tiny-skia" crates/brain-brew-workbench-ui/ Cargo.lock` shows no iced/wgpu/tiny-skia entries attributable to this crate (Cargo.lock loses the iced tree entirely). +- All validation gates green, including full **13/13** `devenv shell e2e`. Zero E2E test-file edits. +- `tests/workspace_summary.rs` unmodified and green. +- `crates/brain-brew-cli/assets/workbench` regenerated via workbench-ui-embed. +- Report: exact Leptos version pinned; whether wasm-bindgen moved off the locked 0.2.125; any Trunk/devenv friction (this stage exists to surface it). + +## What green demonstrates + +Leptos 0.8 CSR builds under nix-pinned Trunk 0.21.14 / wasm-bindgen-cli with the existing `index.html` `data-bin` entry and no Trunk.toml; `mount_to_body` coexists with the static boot anchor and the `set_inner_html` layer; the Iced/wgpu tree is fully excised; and all 13 scenarios survive the swap. If any fails, we spent one Run, not six. + +## Delegation + +- Agentleman Run `agm-run-20260706142040-dilnlr` (workspace handle `wb-leptos-0010`), delegated 2026-07-06, `workspace: true`, maxFixAttempts 3. +- Automated checks wired: `devenv shell test`, `devenv shell workbench-ui-build`, `devenv shell e2e`. +- On done: apply the Fable judge gate against these acceptance criteria before integrating; do not accept an unjudged payload. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Fable judge verdict **ACCEPT** — all 9 acceptance criteria verified against the actual diff; imperative DOM layer untouched (only dead native no-op stubs deleted); 13/13 e2e confirmed in the run transcript; wasm 3.69MB→386KB; zero iced/wgpu strings; exactly 9 changed files; server/e2e-oracle/devenv.nix untouched. Non-blocking notes: added `console_error_panic_hook` (standard, wasm32-only); removed a now-dead Iced-internal status string (never DOM-asserted). Leptos 0.8.20 pinned; wasm-bindgen held at 0.2.125 (no devenv.nix bump needed). + +Integrated into the `default` workspace as jj change `pwtunoykqqky` (commit `0bb…`). Undo: `jj op restore b4d04e758cd5`. + +## Risk + +Toolchain compat is existential. If the trunk build fails on wasm-bindgen version skew (Cargo.lock 0.2.125 vs pinned CLI), the fix is a `devenv.nix` pin bump — **parent-owned; surface it, do not hack around it in the delegate.** diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md b/.frontloop/_archive/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md new file mode 100644 index 0000000..96a2ff0 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md @@ -0,0 +1,36 @@ +--- +title: Leptos-owned panel skeleton — view switcher, view sections, probe, error surface +priority: high +--- + +## Goal + +Make Leptos own the structural DOM the legacy code rebuilds wholesale via `set_inner_html`: `#workbench-dom-panel`, the `#workbench-view-switch` nav and its `.workbench-view-button`s, the four `.workbench-view[data-view]` sections (`notes`/`cards`/`source-strings`/`metadata`, ids `view-panel-notes` etc., lib.rs ~368–427; switch builder ~438), workspace probe updates, and the top-level error display (`publish_note_pivot_error` / `.workbench-error`). View interiors stay legacy: each section exposes a stable inner container that the existing `publish_*` functions render into. + +Depends on: 0010 (Leptos bootstrap in place). + +## Work items + +1. Introduce the reactive spine: signals for `selected_view` (Notes/Cards/SourceStrings/Metadata), workspace summary, top-level error/status. Replace `WORKBENCH_SELECTION_GENERATION` consumers only where they guard *view switching*; leave per-detail generation counters for later stages. Preserve stale-selection semantics exactly — a late response from a superseded selection is dropped, not rendered (E2E `workbench_ignores_stale_language_reload_responses` asserts the stale node is absent from `#workbench-dom-panel`). +2. Re-scope the legacy publish functions minimally so each renders only its view's interior into its section container instead of regenerating the whole panel + switcher. Do not change the HTML they emit inside sections. +3. Keep view activation lazy: switching to a view triggers that view's legacy fetch/publish on first activation; non-active views must not fetch (E2E instruments `window.fetch`, `assert_no_secondary_pivot_fetches`). Preserve `.active`/`[hidden]`/`aria-current="page"` on sections and buttons. +4. The switcher must render OUTSIDE all `.workbench-view` sections (E2E checks the nested case finds nothing). +5. Move probe updates to a Leptos effect if convenient, keeping identical `data-status` transitions/text; keeping the web_sys impl is also fine. +6. Reuse `static/workbench.css` as-is; add no stylesheet unless a class is genuinely missing (report additions). + +## Delegation + +- Agentleman Run `agm-run-20260706152604-7rinyr` (workspace `wb-leptos-0020`), delegated 2026-07-06 via `agm run` + a session-scoped `jjw`→`ajj` shim (the MCP delegate tool is broken by the rename — see [[agentleman-integration-via-ajj]]). Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate, then integrate with `ajj stack wb-leptos-0020 --repo … --yes` + `jj workspace update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Fable judge **ACCEPT**. Leptos now owns `#workbench-dom-panel`, `#workbench-view-switch` + `.workbench-view-button`s, the four `.workbench-view` sections (stable interior containers `#note-pivot-panel`/`#card-pivot-panel`/`#source-string-pivot-panel`/`#optional-metadata-panel`), and a signal-driven `.workbench-error` surface; legacy `publish_*` fill interiors with byte-identical HTML. 13/13 e2e (transcript-evidenced). Single file changed (`lib.rs`, +337/−96). Probe intentionally stays the static `#brainbrew-workbench-e2e` div in index.html (index.html is no-go). Integrated as jj change `skpqtnzu` (commit `66c…`). Undo: `jj op restore f5dc34a6e268`. + +Carry-forward: ~60 lines of dead view-switch code (`workbench_view_switch_html`, `register_view_switch_handlers`, `attach_view_switch_handler`, `activate_workbench_view`) left under `#[allow(dead_code)]` — delete in 0060. + +## Acceptance Criteria + +- All validation gates green; **13/13** E2E; zero E2E test edits (if the Leptos DOM legitimately cannot reproduce a pinned selector, STOP and report rather than editing the test). +- Diff shows the whole-panel `set_inner_html` rebuild replaced by a Leptos-rendered skeleton + per-section legacy publishes. +- Report which publish functions were re-scoped. diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0030-componentize-notes-view.md b/.frontloop/_archive/workbench-leptos-migration/done/0030-componentize-notes-view.md new file mode 100644 index 0000000..a277173 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0030-componentize-notes-view.md @@ -0,0 +1,38 @@ +--- +title: Componentize the notes view — nav, detail, contenteditable fields, card previews, media +priority: high +--- + +## Goal + +Rewrite the notes-view interior as Leptos components, deleting the corresponding legacy string-HTML builders and event closures: note navigation (windowed `.note-navigation-row`, `note-navigation-more`, `navigation-page-summary`), single note detail (`.note-card`, `#note-detail-panel`, `.field-editor` rows), contenteditable field editors (target-translation + source-field, `set_contenteditable`, ids `card-translation-input-*` / `card-source-input-*`), Anki card HTML previews (`note_html` and friends), and media rendering. Staged-edit writes go through a new `staging` module (signals backed by localStorage) preserving the existing key schema exactly. Migrate `NOTE_DETAIL_GENERATION` from `thread_local!` to a signal/`StoredValue` request token with identical drop-stale semantics. Apply box, new-language panel, and multi-pane comparison stay legacy (0040) — keep their publish paths working against the new structure. + +Depends on: 0020. + +## Delegation + +- Agentleman Run `agm-run-20260706155534-tel4o2` (workspace `wb-leptos-0030`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate, then `ajj stack wb-leptos-0030 --repo … --yes` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT (after 1 iterate), integrated + +Notes-view interior componentized to Leptos; new `staging.rs` (signals + localStorage, exact key schema); `NOTE_DETAIL_GENERATION`→`NOTE_DETAIL_TOKEN_SIGNAL`; contenteditable keyed per note+field, never written back to the focused input; 9 legacy note-view functions deleted. Files: `lib.rs` + new `staging.rs`. + +**Iterate:** first Fable pass caught a silent bug — new staging added `context_path` to notes-view contextual edits (legacy handlers never did), which would drift Apply's YAML (field-path context vs consolidated note-level). Fixed via `agm continue` (change `luzywtovqzxo` "preserve note contextual staging shape"): notes view now stages `{kind,path,source,value,mode}`; card/source-string views keep their own legitimate context_path. Re-judged ACCEPT against original criteria. 13/13 e2e post-fix. + +Integrated as changes `xwznzyntnrnk` (migrate) + `luzywtovqzxo` (fix). Undo: `jj op restore a0225891d131`. + +## Critical constraints + +- **contenteditable + reactivity**: never reactively bind element text to the same signal the `input` event writes — re-rendering a focused contenteditable resets the cursor and breaks the typing E2E. Pattern: render initial content once per (note, field) key (key the element on note/field identity so navigation recreates it); handle `on:input` by reading `event_target` textContent into the staging signal; never write that signal back into the focused element. Verify manually via `devenv shell workbench-ui-watch` that typing mid-word keeps the cursor. +- **Media**: `` `src` must be exactly the `/api/media/` URLs the server emits today (rewriting logic ~lib.rs:3801 — port, don't reinterpret). E2E fetches each `src` asserting HTTP status + content-type and checks natural dimensions; a Trunk-mangled/relative URL fails. +- **Row budgets**: preserve windowing — `.note-navigation-row` count and `#note-detail-panel .field-editor tbody tr` count within `WORKBENCH_NAVIGATION_ROW_BUDGET`/`WORKBENCH_DETAIL_ROW_BUDGET`; at most one `.note-card`. Do not render full lists and hide with CSS. +- **localStorage**: staging module reads/writes the exact keys the legacy helpers produce (prefixes + `translation::`/`source::`) — port key-derivation verbatim; E2E refreshes the page expecting staged edits to survive, and the (still-legacy) apply flow reads these keys. +- Preserve stale-note-detail: selecting note B while note A's detail fetch is in flight drops A's response. + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_app_shell_loads_workspace_metadata`, `workbench_ignores_stale_language_reload_responses`, `workbench_edits_target_translation_persists_refresh_and_applies_yaml`, `workbench_edits_source_field_persists_refresh_and_creates_stale_translation`, and the three `workbench_ultimate_geography_*` / `workbench_loads_ug_like_repeated_source_smoke_path` (media + manifest + row budgets). +- Legacy note-view HTML builders and their `Closure` wiring deleted, not shadowed. +- Zero E2E test edits without explicit reported justification. +- Report: how contenteditable is keyed; how the generation token replaced `NOTE_DETAIL_GENERATION`. diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0040-componentize-notes-workflows.md b/.frontloop/_archive/workbench-leptos-migration/done/0040-componentize-notes-workflows.md new file mode 100644 index 0000000..670854e --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0040-componentize-notes-workflows.md @@ -0,0 +1,31 @@ +--- +title: Componentize notes-view workflows — apply box, apply preview, new-language, multi-pane +priority: high +--- + +## Goal + +Rewrite the remaining notes-view surfaces as Leptos components, deleting their legacy builders/closures: the apply box (`.apply-box`; POST `/api/workbench/apply-preview` + `/api/workbench/apply`, lib.rs ~4454), the new-language panel (`.new-language-panel`; GET `new-language-preview`, POST `new-language`, ~3440), and the multi-pane secondary comparison (`publish_secondary_target_pane`, `.pane-layout-panel`, GET `comparison-pane`). These read staged edits from the 0030 staging module and clear/refresh state after apply exactly as the legacy flow does (post-apply refresh must NOT start a new user selection — see comment ~lib.rs:4787; preserve that). + +Depends on: 0030 (staging module + note components). + +## Delegation + +- Agentleman Run `agm-run-20260706170848-1la4br` (workspace `wb-leptos-0040`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate (watch apply/preview/new-language request-JSON parity — highest silent-drift risk), then `ajj stack wb-leptos-0040 --repo … --yes` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Apply box, apply-preview, new-language scaffold, and multi-pane comparison componentized to Leptos. Apply/apply-preview/new-language request JSON verified byte-identical to legacy builders (Fable checked field-for-field — the highest silent-drift risk); staged edits collected via `staging::collect_staged_edits_for_prefixes(active_storage_prefixes(pivot))` (active pivot + secondary panes, scope unchanged); post-apply clear/refresh guard preserved. 17 legacy builders/closures deleted; only `lib.rs` changed; 13/13 e2e. Integrated as change `qulrqznup` "port remaining notes workflow panels to leptos". Undo: `jj op restore 2b691c858d05`. + +## Constraints + +- Request/response payloads to apply/apply-preview/new-language stay identical to what the server expects — port the JSON construction, don't redesign it. +- After a successful apply, staged localStorage entries are cleared the same way. +- Grouped cross-file apply and mixed source+target flows are the most ordering-sensitive: staged source edits must take effect before dependent target rows are computed client-side — port the `effective_source` overlay logic faithfully (~lib.rs:1705/1817/3972/4097). + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_new_language_scaffold_creates_editable_language`, `workbench_multi_pane_layout_applies_grouped_changes_across_files`, `workbench_mixed_source_and_target_browser_apply_uses_new_source`, and re-confirm `workbench_edits_target_translation_persists_refresh_and_applies_yaml` (apply path now componentized). +- Legacy builders for these panels deleted. +- Zero E2E test edits without reported justification. diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md b/.frontloop/_archive/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md new file mode 100644 index 0000000..ddf6721 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md @@ -0,0 +1,39 @@ +--- +title: Componentize card, source-string, and metadata views +priority: high +--- + +## Goal + +Rewrite the three remaining view interiors as Leptos components and delete their legacy counterparts: + +- **Card view**: `publish_card_list_panel`, `publish_card_detail_panel`, `publish_card_pivot_panel`; `#card-pivot-panel`, `.card-row`, card filters; endpoints `card-list`/`card-pivot`. +- **Source-string view**: `publish_source_string_list_panel`, `_detail_panel`, `_pivot_panel`; `#source-string-pivot-panel`, `.source-string-row`, `source-string-contextual-input-*` contenteditable; endpoints `source-string-list`/`source-string-pivot`. +- **Metadata view**: `publish_optional_metadata_panel`, the optional-metadata checklist; endpoints `metadata`/`metadata-list`. + +Migrate `CARD_DETAIL_GENERATION` and `SOURCE_STRING_DETAIL_GENERATION` to the same signal-token pattern from 0030. Card/source-string field editing goes through the shared staging module (same key schema). Follow the same contenteditable, row-budget, and lazy-activation constraints as 0030 (these views must not fetch until first activated; row counts within `WORKBENCH_NAVIGATION_ROW_BUDGET`). + +Also componentize the **global language/target/overlay controls** (`#workbench-global-controls`, filled today by legacy `set_inner_html` at lib.rs ~1633) so that after 0050 the only remaining raw-HTML injection is trusted card/answer preview bodies (rendered via Leptos `inner_html`), leaving 0060 a true purge. If including the global controls makes this stage too large, complete the three views first and report so the controls can be split out. + +Depends on: 0030 (staging module + token pattern), 0040. + +## Delegation + +- Agentleman Run `agm-run-20260706173111-tl5vqe` (workspace `wb-leptos-0050`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- Note: card & source-string staged translations LEGITIMATELY include `context_path` (unlike the notes view) — do NOT strip it. On done: Fable judge, then `ajj stack` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Card, source-string, metadata view interiors AND global controls componentized to Leptos. `CARD_DETAIL_GENERATION`/`SOURCE_STRING_DETAIL_GENERATION` → `CARD_/SOURCE_STRING_DETAIL_TOKEN_SIGNAL` RwSignal tokens. **All `set_inner_html` drained** — 5 remaining `inner_html` sites are trusted deck-authored preview bodies only. `context_path` split correct (card/source-string keep it, notes don't; `staging.rs` byte-unchanged). Lazy activation + pagination preserved. Only `lib.rs` changed; 13/13 e2e (delegate self-corrected a transient 12/1 mid-run). Integrated as change `okolynrzwyoo` "port remaining pivots to leptos". Undo: `jj op restore cc42438c8867`. + +The entire Workbench UI now renders from Leptos. 0060 is a true purge. + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_card_pivot_navigates_and_edits_card_field`, `workbench_source_string_pivot_stages_direct_translation`, `workbench_optional_metadata_checklist_edits_separately`, plus re-run confidence on the notes suite (shared staging module now has three consumers). +- All three views' legacy builders and closures deleted. +- Zero E2E test edits without reported justification. + +## Note + +Largest remaining chunk. Views are structurally similar (list/detail/pivot each) and share the established staging module + token pattern. If it stalls, complete and validate view-by-view and report honestly rather than delivering a broken mixture — the orchestrator can `agm continue` the remainder. diff --git a/.frontloop/_archive/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md b/.frontloop/_archive/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md new file mode 100644 index 0000000..78f9a34 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md @@ -0,0 +1,36 @@ +--- +title: Purge legacy remnants, trim dependencies, embed refresh, full CI +priority: medium +--- + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated — EPIC COMPLETE + +All `thread_local!` removed (selection/note-pivot/detail generations moved into a `WorkbenchSignals` `RwSignal` holder in a `OnceLock`; drop-stale guard + generation semantics verified preserved). `wasm-bindgen-futures` dropped (all 12 `spawn_local` sites → `leptos::task::spawn_local`); `wasm-bindgen`/`gloo-net`/`web-sys` kept as still-used. Dead CSS (`.card-filters`, `.error`) removed. `inner_html` only on trusted deck-preview bodies. `staging.rs` byte-unchanged. `cli.md` + `workbench.md` updated to Leptos; ADRs + research doc left historical. Embedded assets regenerated. **Full `devenv shell ci` green, 13/13 e2e.** Integrated as change `rzvlzorrkynm` "purge leptos migration leftovers". Undo: `jj op restore 1ad53fae7690`. + +**Follow-up for the lead:** supersede ADR-0011 (which committed to Iced) with a new ADR recording the Leptos decision. Delegate recommended it but (correctly) did not write it. + +## Goal + +Finish the migration. Remove every remaining legacy artifact: unused `thread_local!` cells, `set_inner_html`-based helpers, dead `Closure` wiring, orphaned localStorage helper duplicates, and `#[cfg]` scaffolding left from the strangler phases. Trim `Cargo.toml`: drop `web-sys` features no longer referenced (audit each of Document/Element/Event/EventTarget/HtmlInputElement/HtmlSelectElement/Node/NodeList/Storage/Window against actual usage — Leptos re-exports much of this); drop `wasm-bindgen`/`wasm-bindgen-futures` as direct deps if nothing references them directly; keep `gloo-net` only if still the fetch layer. Sanity-pass `static/workbench.css` for selectors no longer matching any rendered DOM — remove only provably dead rules, leave anything E2E references. Update stale in-crate comments/docs still describing Iced or `set_inner_html`. + +Depends on: 0050 (all views componentized). + +## Delegation + +- Agentleman Run `agm-run-20260706175839-nvgybb` (workspace `wb-leptos-0060`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- Sanctioned out-of-crate writes: regenerate `crates/brain-brew-cli/assets/workbench`; factual Iced→Leptos wording in `documentation/docs/reference/cli.md` + `workbench.md` only (ADRs + research doc left historical; delegate recommends ADR-0011 supersession for the lead). On done: Fable judge, then `ajj stack` + `update-stale`. + +Known carry-forward from 0020: delete the dead view-switch code left under `#[allow(dead_code)]` — `workbench_view_switch_html`, `register_view_switch_handlers`, `attach_view_switch_handler`, `activate_workbench_view` (~60 lines). Also audit for additive-but-inert DOM surface introduced during componentization (`data-app-status`, `data-workspace-loaded`, `#workbench-global-controls`, `.workbench-view-interior` with no CSS) — adopt or remove. + +## Narrow out-of-crate exception + +This is the one stage allowed to touch files outside the UI crate, narrowly: regenerate `crates/brain-brew-cli/assets/workbench` via `devenv shell workbench-ui-embed`; and if repo docs/ADRs under `documentation/` describe the workbench frontend as Iced, update those descriptions factually. **No new ADR** — the lead owns the ADR record (ADR-0011 supersession); report what you found if an ADR change seems needed rather than writing one. + +## Acceptance Criteria + +- `rg -in "iced|set_inner_html" crates/brain-brew-workbench-ui/src/` returns nothing — except any deliberate, justified raw-HTML injection for trusted server-provided card-template previews (if Anki preview rendering legitimately needs Leptos `inner_html`, that is expected; report each site). +- No `thread_local!` in the crate. +- Full `devenv shell ci` green (fmt:check + workspace tests + clippy `-D warnings` + complete **13/13** e2e). +- Embedded assets regenerated and included in the payload. +- `tests/workspace_summary.rs` still unmodified and green. +- Report: final dependency list, deleted-line count, CSS rules removed, doc files touched. diff --git a/.frontloop/_archive/workbench-leptos-migration/epic.md b/.frontloop/_archive/workbench-leptos-migration/epic.md new file mode 100644 index 0000000..e8f7655 --- /dev/null +++ b/.frontloop/_archive/workbench-leptos-migration/epic.md @@ -0,0 +1,47 @@ +--- +title: workbench leptos migration +slug: workbench-leptos-migration +status: active +created_at: 2026-07-06 +completed_at: +--- + +## Goal + +Replace the Iced/canvas frontend in `crates/brain-brew-workbench-ui` with **Leptos** (CSR, Rust→WASM DOM), preserving behavioral parity as judged by the 13 thirtyfour E2E scenarios in `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs`, with **zero changes to the server, its `/api/*` endpoints, or the Trunk build/serve/embed pipeline**. + +This epic is the promotion of `default/ready/0130-migrate-workbench-ui-to-declarative-state-driven-architecture` per that task's own guidance ("if it stalls, split into a dedicated epic with the spike as its first task"). The Phase-0 spike is **done**: Iced-on-web renders its widget tree to a `` (wgpu/tiny-skia), emits no DOM, and cannot host Anki HTML/CSS card previews, `contenteditable` editing, or real `` media — which is why the current code bypasses Iced entirely with ~28 raw `set_inner_html`/`web_sys` operations. ADR-0011's actual value ("Rust-native type-safe state/update/view in the browser") is preserved by Leptos while gaining the DOM. **Framework decision: Leptos (final).** + +## Why the staging works (key finding) + +The Iced runtime layer is only ~100 lines: `update()` fires two initial fetches and forwards results to `publish_*`; every interactive DOM closure is self-contained `web_sys` that never dispatches back into Iced. So Stage 1 is a **runtime swap** (Iced out, Leptos bootstrap in, legacy DOM layer retained) that keeps all 13 E2E green immediately, and each later stage is a **strangler-pattern** port of one DOM region — the parity oracle gates every integration. Main is never left with a red suite. + +## Fixed points / hard no-go areas + +- Server + all `/api/*` endpoints (`crates/brain-brew-cli/src/commands/workbench.rs`). Frontend-only. The server already exposes every list/detail/pivot/apply endpoint the UI consumes — no server work. +- `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs` — the **13** scenarios are the acceptance oracle. No test edits without explicit, reported justification. +- `index.html` boot anchor (`#brainbrew-workbench-e2e`), `data-trunk` links, dev-assets (`target/workbench-ui`), release embed (`crates/brain-brew-cli/assets/workbench` via `include_dir!`), and the nix-pinned Trunk pipeline. +- localStorage staged-edit key schema; the DOM contract E2E relies on (row budgets, no nested view switcher, no fetch from inactive pivots, media `src`/dimensions/HTTP status). + +## Validation floor (every task) + +`devenv shell fmt` (then fmt:check clean) · `devenv shell test` · `devenv shell clippy` (`-D warnings`, excl. e2e crate) · `devenv shell workbench-ui-build` · `devenv shell e2e` (all **13/13** green). CLI package/binary is `brainbrew`. + +## Stages (strictly sequential: 0010 → 0020 → 0030 → 0040 → 0050 → 0060) + +- **0010** Runtime swap — remove Iced, boot the existing DOM layer from Leptos (de-risk milestone; delegate first) +- **0020** Leptos-owned panel skeleton — view switcher, view sections, probe, error surface +- **0030** Componentize notes view — nav, detail, contenteditable fields, card previews, media +- **0040** Componentize notes workflows — apply box/preview, new-language panel, multi-pane comparison +- **0050** Componentize card, source-string, metadata views +- **0060** Purge legacy remnants, trim deps, embed refresh, full CI + +Delivery: each stage is a self-contained agentleman Run (`workspace: true`), judged against its acceptance criteria (judge inspects actual E2E evidence), integrated into Main before the next is delegated. + +## Open risks for the lead + +1. **Leptos/Trunk/wasm-bindgen nix-pin compat** is the existential risk — Stage 0010 surfaces it first. If Leptos 0.8 drags `wasm-bindgen` past the pinned CLI, the fix is a `devenv.nix` pin bump (parent-owned, not delegated). +2. **contenteditable cursor-jump** threatens the typing-flow tests — Stage 0030 mandates the uncontrolled-element pattern. +3. **Stale-response guards** ported as explicit generation tokens (not Leptos `Resource` auto-cancel) — a test asserts absence of stale DOM. +4. Task statement originally said 14 scenarios; the file has **13**. +5. Untracked `.frontloop/default/` and repo-root `default/` dirs are unrelated; keep out of Workspace payloads. diff --git a/.frontloop/architecture-performance/epic.md b/.frontloop/architecture-performance/epic.md new file mode 100644 index 0000000..69e0513 --- /dev/null +++ b/.frontloop/architecture-performance/epic.md @@ -0,0 +1,15 @@ +--- +title: Architecture depth and performance +slug: architecture-performance +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Increase locality and leverage after correctness is restored: split oversized implementations behind stable interfaces, deepen typed path and planning modules, memoize target work, bound collection costs, and retain deterministic behavior. + +## Sequence + +Do not begin broad structural splitting before source/core/Workbench safety fixes establish behavior. Then split along behavior seams, optimize measured hot paths with budgets, and verify no interface or deterministic-output regressions. diff --git a/.frontloop/architecture-performance/ready/0010-deepen-deckpath-with-typed-traversal-and-matching-operations.md b/.frontloop/architecture-performance/ready/0010-deepen-deckpath-with-typed-traversal-and-matching-operations.md new file mode 100644 index 0000000..a20fb07 --- /dev/null +++ b/.frontloop/architecture-performance/ready/0010-deepen-deckpath-with-typed-traversal-and-matching-operations.md @@ -0,0 +1,20 @@ +--- +title: Deepen DeckPath with typed traversal and matching operations +priority: medium +--- + +## Goal + +Remove remaining CLI string parsing by exposing typed parent, entity, field, descendant, and pattern operations at the core interface. + +## Acceptance Criteria + +- Translation and Workbench callers no longer parse DeckPath strings manually +- Typed operations cover parent/entity/field access and descendant matching +- Stable display/serialization remains compatible or migrates explicitly +- Property tests cover parse/display/traversal laws +- No filesystem authorization concerns leak into core DeckPath + +## Implementation Notes + +Start after core path/error shapes stabilize. diff --git a/.frontloop/architecture-performance/ready/0020-split-core-composition-along-behavior-seams.md b/.frontloop/architecture-performance/ready/0020-split-core-composition-along-behavior-seams.md new file mode 100644 index 0000000..9d0d3ee --- /dev/null +++ b/.frontloop/architecture-performance/ready/0020-split-core-composition-along-behavior-seams.md @@ -0,0 +1,20 @@ +--- +title: Split core composition along behavior seams +priority: medium +--- + +## Goal + +Decompose the oversized composition implementation into private compose, render, conflict, tombstone, and semantic-diff modules behind the existing deep core interface. + +## Acceptance Criteria + +- Public core interface does not grow merely to mirror implementation pieces +- Conflict/destructive semantics have one implementation location +- Rendering/message and semantic-diff code have focused private modules +- All correctness/property tests remain green +- Module split is behavior-neutral and follows completed core fixes + +## Implementation Notes + +Tidy-first structural work only after core-compose-correctness completes. diff --git a/.frontloop/architecture-performance/ready/0030-split-translation-cli-implementation-by-report-resolve-and-source-editing.md b/.frontloop/architecture-performance/ready/0030-split-translation-cli-implementation-by-report-resolve-and-source-editing.md new file mode 100644 index 0000000..e86dfbb --- /dev/null +++ b/.frontloop/architecture-performance/ready/0030-split-translation-cli-implementation-by-report-resolve-and-source-editing.md @@ -0,0 +1,20 @@ +--- +title: Split translation CLI implementation by report resolve and source editing +priority: medium +--- + +## Goal + +Separate the 3k-line translation command implementation into private modules that consume centralized core policy and canonical source-document interfaces. + +## Acceptance Criteria + +- Report generation, resolution orchestration, source editing, and rendering are distinct private modules +- No translation policy remains duplicated with core or Workbench +- JSON/text renderers share typed report data +- Source editing uses the common mutation interface +- Behavior and output snapshots remain deterministic + +## Implementation Notes + +After translation-integrity and canonical-source-integrity migration tasks. diff --git a/.frontloop/architecture-performance/ready/0040-split-workbench-server-and-ui-by-typed-behavior-modules.md b/.frontloop/architecture-performance/ready/0040-split-workbench-server-and-ui-by-typed-behavior-modules.md new file mode 100644 index 0000000..353c18a --- /dev/null +++ b/.frontloop/architecture-performance/ready/0040-split-workbench-server-and-ui-by-typed-behavior-modules.md @@ -0,0 +1,20 @@ +--- +title: Split Workbench server and UI by typed behavior modules +priority: medium +--- + +## Goal + +Decompose server routes/contracts/cache/apply/source/media/language and UI api/state/views/staging/preview implementations without exposing shallow pass-through interfaces. + +## Acceptance Criteria + +- Modules align with the versioned Workbench contract and ownership of behavior +- Apply safety and cache invariants each have one implementation location +- UI state and views use typed data rather than raw JSON indexing +- Deletion test confirms removed modules would redistribute meaningful complexity +- Embedded and browser tests remain green + +## Implementation Notes + +After Workbench hardening behavior stabilizes; do not repeat the completed Leptos migration. diff --git a/.frontloop/architecture-performance/ready/0050-memoize-registry-loads-and-shared-target-composition-prefixes.md b/.frontloop/architecture-performance/ready/0050-memoize-registry-loads-and-shared-target-composition-prefixes.md new file mode 100644 index 0000000..2531dc2 --- /dev/null +++ b/.frontloop/architecture-performance/ready/0050-memoize-registry-loads-and-shared-target-composition-prefixes.md @@ -0,0 +1,20 @@ +--- +title: Memoize registry loads and shared target composition prefixes +priority: medium +--- + +## Goal + +Reduce 74-target verification from repeated full registry/source/overlay work while preserving exact deterministic diagnostics and output. + +## Acceptance Criteria + +- Manifest registry and immutable source documents load once per command +- Targets sharing base/overlay prefixes reuse validated immutable composition states +- Errors retain target/overlay attribution despite reuse +- 74-target UG benchmark improves against the recorded ~18.9-second baseline +- Cached and uncached results are byte/diagnostic equivalent + +## Implementation Notes + +After consolidated planner and core correctness; optimize measured prefixes before adding parallelism. diff --git a/.frontloop/architecture-performance/ready/0060-stream-and-deduplicate-media-verification-and-export.md b/.frontloop/architecture-performance/ready/0060-stream-and-deduplicate-media-verification-and-export.md new file mode 100644 index 0000000..5fdf8fa --- /dev/null +++ b/.frontloop/architecture-performance/ready/0060-stream-and-deduplicate-media-verification-and-export.md @@ -0,0 +1,20 @@ +--- +title: Stream and deduplicate media verification and export +priority: medium +--- + +## Goal + +Avoid rereading/retaining the same common media bytes for every target while preserving per-package ownership and exact hash/reference failures. + +## Acceptance Criteria + +- Identical owned assets are hashed once per command execution +- Verification streams bytes within bounded memory +- Per-target reference diagnostics remain complete +- Export uses a validated declared-media plan and clean transaction +- Benchmarks cover UG common assets, duplicate references, and large individual files + +## Implementation Notes + +Depends on federated media ownership and transaction modules. diff --git a/.frontloop/architecture-performance/ready/0070-build-compact-paginated-indexes-for-workbench-lists.md b/.frontloop/architecture-performance/ready/0070-build-compact-paginated-indexes-for-workbench-lists.md new file mode 100644 index 0000000..8771ebf --- /dev/null +++ b/.frontloop/architecture-performance/ready/0070-build-compact-paginated-indexes-for-workbench-lists.md @@ -0,0 +1,20 @@ +--- +title: Build compact paginated indexes for Workbench lists +priority: low +--- + +## Goal + +Avoid constructing full note/card/source detail collections before slicing and provide stable cursors/indexes for large decks. + +## Acceptance Criteria + +- List endpoints query compact cached indexes and materialize only requested rows +- Pagination order/cursors are deterministic across refreshes +- Limit 1 is measurably cheaper than limit 50 and full detail +- Invalidation follows complete source fingerprints +- Existing row and new item-51 E2E behavior remains correct + +## Implementation Notes + +After Workbench detail interfaces and cache correctness. diff --git a/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md b/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md new file mode 100644 index 0000000..124137f --- /dev/null +++ b/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md @@ -0,0 +1,37 @@ +--- +title: Report swallowed structured-message errors in render_deck_variables +priority: high +--- + +## Goal + +`render_deck_variables` must fail when structured-message resolution fails after variable substitution, instead of silently returning a deck with broken/empty messages. + +## Problem + +In `crates/brain-brew-core/src/lib.rs` (~line 3623), the success branch does: + +```rust +if errors.is_empty() { + let mut message_errors = Vec::new(); + resolve_structured_messages_with_validation_errors(&mut rendered, &mut message_errors); + Ok(rendered) +} +``` + +`message_errors` is populated by the resolver but never inspected, so any message that fails to resolve post-substitution (e.g. a `ref` to a nonexistent field produced via a variable) is silently dropped. Everywhere else in the pipeline this resolver's errors are surfaced (compose errors, validation errors); this is the only call site that discards them. + +## Acceptance Criteria + +- `render_deck_variables` returns an error when `resolve_structured_messages_with_validation_errors` reports any error. +- The error output identifies the offending path/message, consistent with how compose/validate report the same failures. +- Regression test: a deck whose variable expansion yields an unresolvable structured message causes `render_deck_variables` to fail; the same deck with the variable corrected succeeds. +- `cargo test --workspace` passes. + +## Design Decisions + +- Whether to extend `VariableRenderReport` with the existing validation-error type or add a distinct variant is left to the implementor; prefer whichever keeps error display consistent. + +## Implementation Notes + +- Callers of `render_deck_variables` (e.g. CrowdAnki export path in `crates/brain-brew-formats/src/crowdanki.rs`) already handle the `Err` case, so no caller changes expected beyond error display. diff --git a/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md b/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md new file mode 100644 index 0000000..54a75d8 --- /dev/null +++ b/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md @@ -0,0 +1,29 @@ +--- +title: Fail closed on CrowdAnki import stable-ID collisions +priority: high +--- + +## Goal + +CrowdAnki import must return an error when two source entities derive the same stable ID, instead of silently keeping only the last one. + +## Problem + +In `crates/brain-brew-formats/src/crowdanki.rs` (`into_deck`, ~lines 790-802), stable IDs are slug-derived (note types from model name, notes from first field value; derivation ~lines 1085-1111). Note types are inserted with `BTreeMap::insert` and notes collected into a `BTreeMap` — both last-wins on duplicate keys. Distinct entities whose names/first fields slugify identically (e.g. "São Tomé" vs "Sao Tome", two notes both starting "Congo") silently vanish from the imported deck, which then validates cleanly. Violates ADR-0010 (fail closed on unsupported adapter data) and ADR-0004 (reviewable stable IDs). Field-level duplicates are already caught by validation (`DuplicateFieldDefinition`); note and note-type level are not. + +## Acceptance Criteria + +- Importing a CrowdAnki export where two notes derive the same stable ID returns a `CrowdAnkiError` naming the colliding ID and identifying both source notes (e.g. their GUIDs and/or first-field values). +- Same for two note models deriving the same stable ID (naming both model names). +- Error message points the user at the suggested-IDs override path (`import_deck_accept_suggested_ids`) as the resolution. +- The `note_type_by_uuid` map is also collision-checked (two models with the same UUID should already be impossible upstream, but the derived-ID map must not mask a collision). +- Happy-path round-trip tests still pass; `cargo test --workspace` passes. + +## Process + +Use TDD / red-green-refactor: write the two failing collision tests in `crates/brain-brew-formats/tests/crowdanki.rs` first (duplicate note slug, duplicate note-model slug), confirm they fail with the current silent last-wins behavior, then implement the collision checks, then refactor. + +## Implementation Notes + +- Collision check is a few lines at each insert site (`BTreeMap::insert` returning `Some` / entry API); most of the work is the tests. +- Keep error style consistent with existing `CrowdAnkiError::Unsupported` messages in the same file. diff --git a/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md b/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md new file mode 100644 index 0000000..608ed14 --- /dev/null +++ b/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md @@ -0,0 +1,38 @@ +--- +title: Cache workspace composition in the Workbench server +priority: high +--- + +## Goal + +Workbench request handlers must stop re-planning and re-composing the workspace from disk on every request; per-request work should be bounded (ADR-0015), keyed on the existing freshness generation. + +## Problem + +In `crates/brain-brew-cli/src/commands/workbench.rs`: + +- `media_path_declared` (~lines 978-1009) runs on every `GET /api/media/` and, per request, re-reads the manifest and fully re-plans + re-composes **every overlay of every target** just to check whether the path is declared media. For UG (~80 targets across two manifests, 319 notes, 546 media files) a single note view (flag + map) triggers hundreds of full-deck compositions. +- `current_manifest` (~line 471) re-reads the manifest file per request; `selected_translation_context` (~lines 1011-1072) re-plans and re-composes the selected target per request. +- All of this blocking file IO and CPU-heavy composition runs inline in `async fn` handlers on Tokio worker threads — zero uses of `spawn_blocking` in the crate. +- Minor: `media_file_candidates` (~lines 945-975) probes `external//...` under every ancestor directory of the manifest root — many stat calls per request; looks like a leftover dev-layout hack. + +The server already tracks a freshness generation that bumps when workspace files change (~lines 3525-3579) — the invalidation hook exists but nothing is cached on it. + +## Acceptance Criteria + +- `media_path_declared` is a lookup into a cached set of declared media paths (union across all targets), built at most once per freshness generation. +- Composed deck / translation-context state for the selected target is cached keyed on the freshness generation (and invalidated by apply/new-language writes, which already bump the generation). +- Manifest is not re-read from disk on requests that hit a valid cache. +- Blocking plan/compose/file work in handlers runs via `tokio::task::spawn_blocking` (or an equivalent dedicated worker), not inline on async workers. +- Behavior is unchanged when files change on disk: after an edit, the next request reflects the new state (existing freshness E2E semantics still pass). +- Existing workbench integration tests in `crates/brain-brew-cli/tests/cli.rs` and the E2E suite pass. Add an integration test asserting media requests succeed after a workspace edit (cache invalidation) and that an undeclared path is still rejected. + +## Design Decisions + +- Single task on purpose: the media-path set and composed-state cache share the same generation-keyed invalidation; implement one caching layer, two consumers. +- Consider tightening `media_file_candidates` to manifest root + media root only, but do not break the documented `--media-root` behavior; if the ancestor probing is load-bearing for some workflow, keep it and note why. + +## Implementation Notes + +- Cache lives on the shared server state struct behind a `tokio::sync::RwLock` (or `arc-swap`); key = generation number. +- `compose_lenient_translation_overlay` (duplicated at `workbench.rs:~4414` and `translations.rs:~1314`) is in the hot path; hoisting it to a shared location can be done here or left to the separate dedup task. diff --git a/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md b/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md new file mode 100644 index 0000000..fb29848 --- /dev/null +++ b/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md @@ -0,0 +1,34 @@ +--- +title: Refresh the UG fixture from upstream via a repeatable sync script +priority: medium +--- + +## Goal + +`fixtures/ultimate-geography/` matches the real ultimate-geography repo (current layout, all 16 languages including Hebrew, both manifests), plus one deliberate, documented delta: the ADR-0012 `languages:`/`translation_profile` catalog. A checked-in script makes the refresh repeatable whenever UG moves again. + +## Problem + +The fixture is a mid-migration snapshot that has drifted from the real repo in both directions: + +- Behind: pre-refactor `source/` include layout (real repo uses `templates//{question,answer}.html`); missing Hebrew (`he.yaml` — the only RTL language, so script direction is currently untested here); missing the hardcore second manifest (the real repo builds two manifests; the hardcore shape earned two high-priority tasks in UG's pr736-review epic and is unrepresented in this repo's tests). +- Ahead: carries the ADR-0012 `languages:`/`translation_profile` catalog (~240 lines of `brainbrew.yaml`) that the real UG doesn't use yet. + +Every "composes the UG fixture byte-identically" acceptance criterion in the queued refactor tasks is only as strong as the fixture's resemblance to production; right now it certifies a deck shape that no longer exists. + +## Acceptance Criteria + +- A script (e.g. `scripts/sync-ug-fixture.sh` or a small Rust/xtask — implementer's choice) that: + - Copies the fixture-relevant files from a UG checkout (path passed as an argument; default may assume `../external/ultimate-geography` but must not hardcode-require it) into `fixtures/ultimate-geography/`: both manifests, all overlays, templates, deck sources, and whatever media/metadata files the fixture tests need. + - Reapplies the translation_profile delta on top. Keep the delta maintainable: either a patch file or a separate YAML fragment the script splices in — NOT hand-editing after each sync. The delta and its rationale (ADR-0012 coverage until UG adopts it upstream — see the UG-side frontloop task) are documented in a README next to the script or fixture. + - Is idempotent: running it twice produces no diff. +- The fixture after refresh: current UG layout, 16 languages including `he.yaml`, both `brainbrew.yaml` and `brainbrew-hardcore.yaml`, plus the translation_profile delta. +- All fixture-dependent tests updated and passing: `cargo test --workspace`. Expect churn in expected outputs (`ultimate_geography_fixture.rs`, overlay/canonical-yaml tests, docs examples that cite fixture facts) — update expectations to the new fixture, but investigate any change that looks semantic rather than layout-driven before accepting it. +- Hardcore manifest is actually exercised: at least one test composes/verifies a hardcore target (new coverage — the shape was previously untested here). +- The sync script's usage is documented (one paragraph: when to run it, what the delta is). + +## Design Decisions + +- The fixture intentionally stays "real UG + translation_profile" rather than splitting into two fixtures; the delta is temporary — a UG-side task exists to adopt translation_profile upstream, after which the delta (and splice step) can be deleted. +- Sync direction is one-way (UG → fixture). The script must never write into the UG checkout. +- If some UG files are irrelevant to tests (e.g. full media binaries), the script may exclude them, but the exclusion list lives in the script, not in tribal knowledge. diff --git a/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md b/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md new file mode 100644 index 0000000..c9c48a6 --- /dev/null +++ b/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md @@ -0,0 +1,37 @@ +--- +title: Unify translation resolution into a single resolver +priority: high +--- + +## Goal + +One implementation of the translation decision procedure (direct / contextual / no_change / stale / missing), consumed by both the apply path and the coverage path, replacing the current hand-synced parallel copies. + +## Problem + +In `crates/brain-brew-core/src/lib.rs` the same ~100-line resolution decision tree is written out longhand at least four times: + +- `TranslationApplyContext::translate_string` (~:1909-2037) — mutating apply path used by compose. +- `TranslationCoverageBuilder::record_string` (~:924-1050) — read-only classification used by verify/reports. +- `translate_optional_string` (~:2039) and `record_optional_string` (~:1052) — near-identical nullable-field twins. +- `has_explicit_string_entry` is duplicated verbatim in both structs (~:894-922 and ~:1879-1907). + +Nothing enforces agreement between the copies. Divergence means `verify` reports coverage that differs from what compose actually produces — breaking the tool's core promise that what verify says is what ships. + +## Acceptance Criteria + +- A single resolver function/type takes (source string, path, dictionary, options) and returns a `TranslationOutcome` enum (e.g. `Direct(value)` / `Contextual { value, context }` / `NoChange` / `Stale(record)` / `Missing`). +- `TranslationApplyContext` and `TranslationCoverageBuilder` are thin consumers mapping outcomes to mutations/errors and report entries respectively; the `_optional` variants collapse into `Option` handling at call sites; `has_explicit_string_entry` exists exactly once. +- Behavior-preserving: all existing tests pass unchanged (33 `overlay_compose` tests, translation CLI tests, UG fixture tests). If the parallel copies turn out to have ALREADY drifted (tests or UG fixture composition reveal a semantic difference between apply and coverage), STOP and report the divergence for a human decision rather than silently picking one side. +- Composing the full ultimate-geography fixture (`fixtures/ultimate-geography`) produces byte-identical output before and after the refactor (capture a before snapshot as evidence). +- `cargo test --workspace` passes. + +## Design Decisions + +- Pure refactor — no semantic changes, no new features. Any tempting behavior fix found along the way becomes a separate task. +- Resolver should be a free function or small struct in core, not a trait hierarchy; keep it as boring as possible. + +## Implementation Notes + +- The translation subsystem spans roughly lib.rs:722-2331; this refactor is a natural precursor to splitting lib.rs into submodules (separate task) — do this one first so the module split moves one resolver, not four copies. +- Guardrails are strong: work red-green against the existing suites; diff the resolution behavior across the 16 UG language overlays as an end-to-end check. diff --git a/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md b/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md new file mode 100644 index 0000000..6c34d14 --- /dev/null +++ b/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md @@ -0,0 +1,38 @@ +--- +title: Unify YAML scalar emission and test the byte-stability promise +priority: high +--- + +## Goal + +One shared scalar-emission implementation for all hand-rolled YAML emitters, with an adversarial test suite that actually pins the ADR-0005 promises: idempotent formatting and lossless round-trip. + +## Problem + +Emission of canonical YAML is hand-rolled (parsing is serde_yaml) per ADR-0005's determinism goal, but: + +- The "can this string be emitted as a plain scalar" predicate is copied three times with different rules: `crates/brain-brew-formats/src/canonical_yaml.rs` ~:1044 (disallows `:`), `manifest.rs` ~:438 (allows `:`), `lockfile.rs` ~:102 (allows `:`). These predicates are correctness-load-bearing (a bare string that YAML re-parses differently — `yes`, `1.0`, trailing `:` — is silent data corruption); a fix to one copy won't reach the others. +- Block-scalar emission (`write_multiline_or_scalar`, canonical_yaml.rs ~:1031) emits `|`/`|-` with no explicit indentation indicator; content with a leading-space first line or trailing whitespace on lines is the classic hand-rolled-emitter corruption case. UG's multiline HTML template fields go through this path. +- `can_emit_plain_scalar` (~:1052) force-single-quotes all non-ASCII, so most content in 15 of UG's 16 language overlays is quoted — deterministic but noisy for the files translators edit (nice-to-have to relax, severable). +- The ADR-0005 promises are barely tested: round-trip tests assert semantic equality; only one byte-level assertion exists (`tests/canonical_yaml.rs` ~:71). No idempotence (`format(format(x)) == format(x)`) or adversarial-content tests. + +## Process + +TDD / red-green-refactor. Write the adversarial test suite FIRST against the current emitters: + +1. Table-driven round-trip tests (`parse(emit(x)) == x`) over hostile strings: leading/trailing whitespace, lines with trailing spaces, first line starting with a space, YAML keyword lookalikes (`yes`, `no`, `null`, `~`, `1.0`, `0x1F`), embedded single/double quotes, `: ` inside strings, `#`, non-ASCII (accented, CJK, Hebrew/RTL), empty string, lone newline. +2. Idempotence tests: format-twice-compare bytes, for unit cases and the full `fixtures/ultimate-geography` deck + overlays + manifest + a lockfile. +3. Any red test is a real pre-existing bug: fix it in the shared implementation (green), then refactor the three emitters onto it. + +## Acceptance Criteria + +- A single shared scalar-emission module in the formats crate (plain-scalar predicate, quoting routine, block-scalar writer) used by canonical_yaml, manifest, and lockfile emitters; the `:` discrepancy is reconciled deliberately (document which rule won and why). +- The adversarial round-trip + idempotence suite above exists and passes. +- Formatting the full ultimate-geography fixture is byte-identical before and after the refactor, EXCEPT where a red test proved the old bytes were corrupt or where the reconciled `:` rule changes quoting — capture and report any such diffs explicitly. +- Optional/severable: relax the plain-scalar rule to allow common non-ASCII word characters only if the round-trip suite proves it safe; skip if in doubt. +- `cargo test --workspace` passes. + +## Design Decisions + +- Do NOT replace the hand-rolled emitter with serde_yaml emission — determinism/byte-stability is the point (ADR-0005). This task consolidates and hardens, not rewrites. +- Emitting an explicit block-scalar indentation indicator (e.g. `|2`) or falling back to quoted style for pathological content are both acceptable; correctness of round-trip beats prettiness. diff --git a/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md b/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md new file mode 100644 index 0000000..58a1a14 --- /dev/null +++ b/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md @@ -0,0 +1,41 @@ +--- +title: Make fmt and format verification preserve source !include structure +priority: high +--- + +## Goal + +`brainbrew fmt` never changes document structure: `!include` markers survive formatting verbatim. Format verification actually verifies include-bearing files instead of silently passing them. + +## Problem + +Compose/export resolve `!include` correctly on read, and the workbench apply path preserves include structure when writing (`workbench.rs` ~:3681-3752, raw parse keeping tagged scalars, edits routed into the included file). But two paths break the contract: + +1. **`fmt` destructively materializes includes.** `fmt.rs` ~:17-20 reads the file, calls `format_source_at`, writes the result back. `format_source_at` (`crates/brain-brew-cli/src/io.rs` ~:37-41) resolves includes BEFORE formatting — so `brainbrew fmt` on an include-bearing deck (like real UG's, whose templates/descriptions are all externalized via `!include`) inlines every included file's content into the source and severs every include link. A formatter that changes structure is a data-loss bug in the most routine command. +2. **Format verification fails open on includes.** `verify_format_with` (`io.rs` ~:517-537) formats the include-resolved text, then — if the raw file contained `!include` — returns `Ok(())` unconditionally (the comment admits the bytes can't be compared). Canonical-format enforcement therefore silently does not apply to exactly the files UG cares most about; a malformed include-bearing file passes verify forever. Opposite of ADR-0010's fail-closed posture. + +## Process + +TDD / red-green-refactor. Failing tests first: + +1. `fmt` on an include-bearing deck fixture leaves every `!include` marker byte-intact and does not inline included content (currently fails: includes are materialized). +2. `fmt` on an include-bearing file is idempotent: second run produces zero diff. +3. `fmt` still normalizes the non-include parts of an include-bearing file (mis-formatted sibling fields get fixed) — preserving includes must not mean skipping formatting. +4. `verify` format check REJECTS a non-canonical include-bearing file (currently fails: unconditional Ok) and accepts a canonical one. +5. Include resolution behavior in compose/export/read paths is unchanged (existing tests stay green). + +Then implement (green), then refactor `format_source_at`/`verify_format_with` and the emitter onto the shared include-preserving path. + +## Acceptance Criteria + +- The canonical emitters can emit an `!include ` tagged scalar verbatim where one appeared in the source (serde_yaml `Value::Tagged` on the parse side; the workbench apply path shows the raw-parse-preserving approach). +- `format_source_at` formats the RAW document (includes preserved); include resolution remains a read-path concern only. No fmt code path writes materialized include content into a source file. +- `verify_format_with` compares canonical bytes for include-bearing files like any other file; the fail-open early-return and its comment are gone. +- Included files themselves (e.g. `.html`) are opaque content: fmt does not read, format, or touch them. +- Run the real check: fmt on the refreshed UG fixture (or a fixture file using the current UG include layout) round-trips with includes intact. +- `cargo test --workspace` passes. + +## Design Decisions + +- The include-preserving format capability should be ONE implementation shared by fmt, verify, and (if it can adopt it cheaply) the workbench apply serializer — not a fourth include-handling variant. +- Related but out of scope: UG-side task `10-low-review-include-materialization-workaround-in-pr-736-evidence-script.md` can only fully remove its workaround once this lands; note completion there. diff --git a/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md b/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md new file mode 100644 index 0000000..4baaeab --- /dev/null +++ b/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md @@ -0,0 +1,31 @@ +--- +title: Enforce media integrity in verify and export +priority: high +--- + +## Goal + +The media declaration model becomes load-bearing: verify fails closed on media references with no declaration and on declarations whose sha256 is empty or doesn't match the file; export ships declared media itself so consumers stop hand-copying; a tooling command populates/refreshes hashes. An image update is thereby recorded in source state (hash bump, git-visible), not just in the binary file. + +## Problem + +Verified against the real UG repo (2026-07-03): + +- All 546 declared media entries in UG's `deck.yaml` have `sha256: ''` — the integrity field is populated nowhere and enforced nowhere. A corrupted or silently-swapped media file passes `verify --media-root`. +- 61 files on disk are referenced from field content (hardcore extension overlays: `overlays/extensions/hardcore.yaml`, `hardcore/field-fills.yaml` — e.g. `ug-flag-bali-blur.png`) but declared nowhere. Nothing cross-checks referenced-in-content vs declared, so verify can't see it (ADR-0010 says this should fail closed). +- UG's CI (`integrity-check.yml:44`) and CONTRIBUTING hand-`cp media/*` into export output — the blanket copy is what masks the 61 missing declarations, and it means export is not authoritative for media in the tool's flagship deployment. + +## Acceptance Criteria + +- **Referenced-vs-declared check in verify**: scan composed field content of every target for media references (initially: ``/`[sound:]`-style extraction over rendered fields; exact-match upgrade comes later via the separate `!image` structured-reference task) and fail with a listing of referenced-but-undeclared paths. Also report declared-but-unreferenced as a warning (unused declarations are noise, not corruption). +- **Hash enforcement in verify**: with `--media-root`, a declared entry whose file is missing, whose sha256 is empty, or whose file hash differs from the declared sha256 is an error naming the entry, the path, and both hashes. (Provide a migration flag or staged severity only if UG adoption needs it; default is fail closed.) +- **Hash tooling**: a command (e.g. `brainbrew media hash --manifest ... --media-root ...`) that computes and writes sha256s into the deck source for missing/stale entries, emitting canonical YAML (respects the include-preserving formatter once that task lands). Refreshing after an intentional image edit is this one command. +- **Authoritative export**: `export crowdanki` copies the declared media set for the target into the output media directory itself; document that consumers can drop manual `cp media/*` steps. +- Tests: fixture with an undeclared-but-referenced file (verify fails), a hash mismatch (verify fails), empty hash (verify fails), and a full green path where export output contains exactly the declared media. Run against the UG fixture once refreshed. +- `cargo test --workspace` passes. + +## Design Decisions + +- Overlays can already add/change media (`media_changes`, full ChangeIntent semantics) — no model change needed; the UG-side fix declares the hardcore media in the hardcore extension overlay. This task is enforcement + tooling only. +- The content-scan reference extractor should be one clearly-named function so the future `!image` structured reference can replace its innards without touching verify's logic. +- CrowdAnki `media_files`/export layout must keep matching what Anki imports expect — the authoritative copy replicates what the manual `cp` achieved for declared files, it does not invent a new layout. diff --git a/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md b/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md new file mode 100644 index 0000000..0ceee04 --- /dev/null +++ b/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md @@ -0,0 +1,30 @@ +--- +title: Dedupe scattered verbatim copy-paste sites +priority: low +--- + +## Goal + +One behavior-preserving sweep that removes the remaining verbatim code duplication, so each of these pieces of logic exists exactly once. (The two load-bearing duplications — translation resolution and YAML scalar emission — have their own dedicated tasks and are OUT of scope here.) + +## Problem + +Four independent copy-paste sites, each a divergence waiting to happen: + +1. **`compose_lenient_translation_overlay`** — ~63 lines byte-identical in `crates/brain-brew-cli/src/commands/workbench.rs` ~:4414 and `crates/brain-brew-cli/src/commands/translations.rs` ~:1314. This defines the "compose without failing on missing translations" semantics both the workbench and the translations CLI rely on; a fix to one copy silently misses the other and the two frontends then disagree about current deck state. +2. **`glob_matches`** — byte-identical in `crates/brain-brew-core/src/lib.rs` ~:2316 and `crates/brain-brew-formats/src/crowdanki.rs` ~:480. Also a hand-rolled recursive backtracker with exponential worst case on patterns with multiple `*`. Not exploitable today (patterns are short and user-authored) but there is no reason for two copies or the exponential body. +3. **`resolve_structured_messages_with_{validation,compose}_errors`** — `crates/brain-brew-core/src/lib.rs` ~:508 and ~:533, identical except which error variant they push. Both also take a full `deck.clone()` snapshot, and the pair runs once per overlay during compose — the largest contributor to compose's clone-per-overlay cost. +4. **Stale-translation warning loop** — the same collect-and-format loop appears three times in `crates/brain-brew-cli/src/commands/verify.rs` (~:114-133, ~:163, ~:190). Consolidation only; the risk is wording/behavior drift between verify output paths. + +## Acceptance Criteria + +- `compose_lenient_translation_overlay` exists exactly once, in a location importable by both `workbench.rs` and `translations.rs` (a shared module in the CLI crate is fine; core is fine too if it fits without dragging CLI concerns in). Note: the workbench composition-cache task also touches this hot path — coordinate if both are in flight. +- `glob_matches` exists exactly once (in core, called or re-exported from formats), and its body is replaced with a linear-time two-pointer match (or the `globset` crate). Behavior pinned by a table-driven test over patterns/inputs covering `*` at start/middle/end, multiple `*`, literal-only, empty pattern, empty input — written BEFORE swapping the body. +- The two `resolve_structured_messages_with_*_errors` functions collapse into one implementation, generic over the error constructor or returning a neutral error type both callers map. If the deck clone can be replaced with borrows of the maps actually needed, do it; if that turns invasive, keep the clone and note it — that half is severable. +- The verify.rs stale-warning loop exists exactly once; the three call sites produce byte-identical output to before (pin with a test or capture before/after on the UG fixture). +- Behavior-preserving throughout: `cargo test --workspace` passes with no test expectation changes (new tests may be added). + +## Design Decisions + +- Explicitly out of scope: the ~250 `.expect("writing to a string cannot fail")` calls on `write!`-to-String — genuinely infallible, idiomatic, leave them alone. +- No semantic changes anywhere; any tempting behavior fix discovered along the way becomes a separate task. diff --git a/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md b/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md new file mode 100644 index 0000000..41180b7 --- /dev/null +++ b/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md @@ -0,0 +1,38 @@ +--- +title: Split brain-brew-core lib.rs into submodules +priority: medium +--- + +## Goal + +`crates/brain-brew-core/src/lib.rs` (currently one flat ~4,662-line module) is split into focused submodules with the public API unchanged, so the crate's internal boundaries are enforced by the module system instead of by discipline. + +## Problem + +The entire core crate — deck model, overlay compose, the translation subsystem (~1,600 lines, roughly :722-2331), structured messages, validation, variable rendering — lives in a single flat `lib.rs`: + +- No enforced boundaries: everything can reach everything's private items; translation code can poke at compose internals and vice versa. Several already-filed duplications (twin translation resolvers, twin `has_explicit_string_entry`) survived partly because a 4,662-line file hides the same logic existing twice a thousand lines apart. +- Zero unit tests in core: all coverage is black-box in `tests/` (good, but the layout discourages small private-function tests next to a `mod translation`). +- Review friction: every core change diffs the same file; "did this translation change touch compose?" means scrolling, not reading a module path. + +The formats crate already splits by codec — this file is the outlier, not the house style. + +## Sequencing (IMPORTANT) + +Do this AFTER these already-filed tasks land, so the split moves one resolver instead of four copies and the same lines aren't churned twice: + +1. `0050-unify-translation-resolution-into-single-resolver.md` +2. `0090-dedupe-verbatim-copy-paste-sites.md` (at least item 3, the `resolve_structured_messages_with_*_errors` merge) + +## Acceptance Criteria + +- `lib.rs` becomes a thin root: module declarations + `pub use` re-exports. Suggested split (adjust to the natural seams found while moving): `model`, `compose`, `translation`, `messages`, `validate`. +- Pure code motion: no function bodies change, no visibility widens beyond what compilation requires (prefer `pub(crate)` over `pub` for items that were previously private-in-module). +- Public API of the crate is unchanged — downstream crates (`brain-brew-formats`, `brain-brew-cli`) compile without source changes to their imports (re-exports preserve paths). +- `cargo test --workspace` passes with zero test changes. +- Composing the full `fixtures/ultimate-geography` deck produces byte-identical output before and after (trivially true for code motion; capture as evidence anyway). + +## Design Decisions + +- No behavior changes, no dedup, no renames beyond module paths — any improvement noticed while moving code becomes a separate task. +- Keep it to one level of modules; don't design a deep hierarchy speculatively. diff --git a/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md b/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md new file mode 100644 index 0000000..f0d4fd8 --- /dev/null +++ b/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md @@ -0,0 +1,43 @@ +--- +title: Replace dotted-string deck paths with a typed DeckPath +priority: medium +--- + +## Goal + +Deck-internal addresses ("field `capital` of note `germany`") are represented by a typed `DeckPath` enum with one parser and one printer, instead of `format!`-assembled dotted strings re-parsed by multiple independent hand-written matchers. Serialized YAML forms stay byte-identical. + +## Problem + +In `crates/brain-brew-core/src/lib.rs`, paths like `notes.germany.fields.capital` are built with scattered `format!` calls and re-parsed by at least three independent parsers: + +- `note_field_path_parts` (~:696) — splits and pattern-matches segments +- `note_id_from_translation_path` (~:1353) — a second parser of the same mini-language for the translation subsystem +- `context_parent_candidates` (~:2251) — string-slices dotted prefixes to find contextual-dictionary parents + +Costs: + +- The compiler can't check any of it: a typo'd `format!` segment produces a path that never matches — a silent miss (translation stops applying, coverage never resolves), not an error. +- The path grammar exists only as folklore across the parsers, and nothing keeps them in agreement. +- Escaping is undefined: a `.` inside a note/field StableId (nothing forbids it; IDs are human-authored) mis-splits in every parser. Latent today because UG's IDs are dot-free, but it is a data-dependent correctness hole. + +## Process + +TDD / red-green-refactor: + +1. First pin the grammar: `FromStr`/`Display` round-trip tests for every legal path shape currently in use (enumerate them by auditing all construction and parse sites), plus hostile cases — unknown segment kinds, wrong arity, empty segments, and IDs containing `.`. +2. The dotted-ID case forces the design decision below; make it explicitly, then implement `DeckPath` (green). +3. Migrate call sites: construction `format!`s → constructors; hand parsers → `FromStr` + match. Refactor until no hand-rolled dotted-string parsing of deck paths remains in core. + +## Acceptance Criteria + +- A `DeckPath` enum in core (variants for each path shape actually used: note field, note-model template, etc.) with `Display` emitting the exact current dotted syntax and `FromStr` as the single parser. +- `note_field_path_parts`, `note_id_from_translation_path`, and the prefix-walking in `context_parent_candidates` are expressed via `DeckPath` (deleted or reduced to thin wrappers); no remaining `format!` construction of deck paths outside `Display`. +- Dots-in-IDs decision made and enforced: EITHER reject `.` in StableIds at validation time (preferred if UG data allows — it does today) OR define and test escaping. Document the choice in the code. +- All existing serialized output is byte-identical: composing the full `fixtures/ultimate-geography` deck and formatting all fixture YAML produces identical bytes before and after. +- `cargo test --workspace` passes; existing tests unchanged except where they construct paths as raw strings internally (may switch to constructors). + +## Design Decisions + +- Internal type change only — the on-disk dotted syntax is a stable format and does not change. +- Sequence AFTER `0100-split-brain-brew-core-lib-into-submodules.md` (the type belongs in the `model` module) and after the translation-resolver unification, which touches the same parsing sites. diff --git a/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md b/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md new file mode 100644 index 0000000..58de4dc --- /dev/null +++ b/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md @@ -0,0 +1,42 @@ +--- +title: Make Workbench apply writes atomic and all-or-nothing +priority: medium +--- + +## Goal + +A workbench Apply either fully lands on disk or changes nothing. No torn files, no partially-applied multi-file edits, no interleaved concurrent applies. + +## Problem + +In `crates/brain-brew-cli/src/commands/workbench.rs`, the apply paths — `apply_request_json` (~:744, writes ~:874-887), `apply_staged_source_edits` (~:3666), `apply_new_language_request` (~:1624) — write multiple source files (base deck + overlays) sequentially and in place: + +- A failure mid-sequence (validation error while serializing a later file, disk full, process death) leaves the git-tracked source of truth in a state no single user action produces: base and overlay disagree, and verify may not catch it. +- An in-place write that dies partway leaves truncated YAML; the strict parser then rejects the whole workspace. +- No locking: two concurrent apply requests (or apply racing another handler's read) can interleave. + +Recovery today is "git checkout" — acceptable for disaster, but the workbench should be trustworthy over source files by construction. + +## Process + +TDD / red-green-refactor. Write failing tests first: + +1. An apply whose second output file fails validation/serialization must leave ALL files byte-identical to their pre-apply state (currently fails: first file is already written). +2. Simulated write failure during the write phase (e.g. injectable writer or read-only target file) must leave previously-written targets untouched (temp files may remain; targets must not change). +3. Torn-file protection: no code path writes a target file in place (assert temp+rename via the shared write helper). +4. Two concurrent apply requests are serialized: final state equals one apply followed by the other, never an interleaving. + +Then implement (green), then refactor the three apply paths onto one shared write-transaction helper. + +## Acceptance Criteria + +- All apply outputs are serialized and validated in memory BEFORE any file IO begins. +- Each file is written temp-file-in-same-directory + fsync + rename; a shared helper is the only way apply code touches disk. +- Apply handlers are mutually exclusive (mutex on the workspace state), and an apply bumps the freshness generation exactly once, after the write phase completes. +- On a rename-phase failure, the error response lists exactly which files were updated and which were not. +- Existing workbench integration tests and E2E pass; `cargo test --workspace` passes. + +## Design Decisions + +- Scope is the workbench apply/new-language write paths. If other CLI writers (e.g. `translations --apply` in translations.rs) can share the helper trivially, do so; otherwise leave them for a follow-up. +- No cross-file journal/WAL — temp+rename per file after full up-front serialization is sufficient for a single-user local tool; the failure window between renames is acceptable when reported precisely. diff --git a/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md b/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md new file mode 100644 index 0000000..1212592 --- /dev/null +++ b/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md @@ -0,0 +1,48 @@ +--- +title: Migrate Workbench UI to a declarative state-driven architecture +priority: medium +--- + +## Resolution: promoted to the `workbench-leptos-migration` epic (2026-07-06) + +This placeholder is superseded. Per its own guidance ("if it stalls, split into a dedicated epic with the spike as its first task"), it has been promoted to a dedicated epic. The **Phase-0 spike is done**: Iced-on-web renders to a `` (wgpu/tiny-skia), emits no DOM, and cannot express Anki HTML/CSS card previews, `contenteditable` editing, or real `` media — which is exactly why the current code bypasses Iced with ~28 raw `set_inner_html`/`web_sys` ops. **Framework chosen: Leptos** (CSR, Rust→WASM DOM), which keeps ADR-0011's "Rust-native type-safe state/update/view" while gaining the DOM. + +The work is now tracked as six sequential tasks under `.frontloop/workbench-leptos-migration/` (0010 runtime swap → 0020 skeleton → 0030 notes view → 0040 notes workflows → 0050 card/source-string/metadata views → 0060 purge/CI). The ADR-0011 supersession is deferred to that epic's 0060 (lead owns the ADR record). + +Original placeholder text preserved below for reference. + +--- + +## Goal + +Replace the hand-rolled HTML-string/`innerHTML` layer in `crates/brain-brew-workbench-ui` with a real state→view architecture, so the UI renders deterministically from typed state, as ADR-0011 promised. No one uses the workbench yet, so breaking changes are acceptable; correctness and determinism over compatibility. + +## Problem + +ADR-0011 commits to an Iced/WASM state architecture, but the Iced app in `crates/brain-brew-workbench-ui/src/lib.rs` is a vestigial three-panel shell (view/update handle ~3 messages, ~lines 131-207). The entire real workbench (note list/pivot, card detail, source-string editing, staged edits, Apply) is built by `format!`-assembling HTML strings and injecting them via `web_sys` `innerHTML` with manually managed `Closure` event handlers (`publish_note_pivot_panel` ~:271, `note_html` ~:658, `card_detail_html` ~:1133). Concrete costs: + +- `note_html` is one `format!` with ~46 positional arguments (~lines 717-761); several builders share this shape. Positional drift is silent and the compiler cannot help. +- Two hand-rolled HTML escapers disagree: server `html_attribute_escape` escapes `'` (`workbench.rs` ~:3557), UI `escape_html` does not (~lib.rs:4754) — an injection seam in single-quoted attributes. +- E2E tests can only assert on raw DOM markup, so any markup change breaks them (against ADR-0014 intent). +- Stale artifact: UI status text says "loaded from /api/workbench/note-pivot" (~lib.rs:115) while code calls `/note-list` (~:4815). + +## Acceptance Criteria + +- All UI panels render from typed Rust state through the chosen framework's view layer; zero `innerHTML` string injection and zero hand-built HTML `format!` strings remain in `brain-brew-workbench-ui`. +- All event handling goes through the framework's message/event system; no manually managed `web_sys::Closure` listeners remain. +- Escaping is handled by the framework (or one shared audited escaper if any raw HTML rendering of deck content is still required, e.g. card previews — that path must be explicit and documented). +- ADR-0011 is superseded/amended by a new ADR recording the chosen framework and why. +- The existing E2E suite (`brain-brew-workbench-e2e`) passes, updated where markup assertions must change; prefer data-testid/semantic hooks over structural selectors while updating. +- Workbench API surface in the CLI crate is unchanged (this is a UI-crate migration). + +## Design Decisions + +- Phase 0 spike (timeboxed): evaluate whether current Iced WASM/DOM support can express the workbench (forms, text editing, lists, HTML card preview). If yes, fulfill ADR-0011 with Iced. If not, pick a declarative Rust WASM framework (Leptos or Dioxus are the expected candidates) and record the choice in the superseding ADR. +- Migrate panel-by-panel (note list → note pivot → card detail → apply flow), keeping the app shippable and E2E green at each step, rather than a big-bang rewrite. +- Card/answer HTML previews render deck-authored HTML by design; sandbox them (dedicated container, explicit trusted-content boundary) rather than letting deck content share the app's DOM context. + +## Implementation Notes + +- Server endpoints already return JSON (ADR-0015 list/detail shape); the UI migration should not need server changes beyond possibly retiring the legacy full-pivot scaffolding routes. +- Fix the stale status-string endpoint mention as part of the first migrated panel. +- This task is large; if it stalls, split into a dedicated epic with the spike as its first task. diff --git a/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md b/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md new file mode 100644 index 0000000..546e386 --- /dev/null +++ b/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md @@ -0,0 +1,41 @@ +--- +title: Wire stale-translation resolution into the CLI and Workbench +priority: high +--- + +## Goal + +A stale translation record can be resolved through the tool — "confirm still correct" (promote the existing translation under the new source text) or "replace" (provide a new translation, retire the stale record) — in both the translations CLI and the Workbench, completing the ADR-0013 workflow. Hand-editing overlay YAML stops being the only resolution path. + +## Problem + +Detection is thoroughly built: compose flags stale records, verify warns, the translations CLI counts/color-codes five stale coverage categories. But resolution exists nowhere: + +- `TranslationDictionary::resolve_stale_translation(old_source, new_source, context)` (`crates/brain-brew-core/src/lib.rs` ~:3996) — the purpose-built promote mutation, tested in `overlay_compose.rs` (~:490, :496) — has ZERO non-test callers. +- The Workbench, which ADR-0013 explicitly casts as the resolve surface, has no stale-resolution endpoint at all. +- The CLI apply flow detects stale rows and skips them (`translations.rs` ~:1096, "no safe automatic rewrite is applied, skipping") — correct fail-safe, but the guided flow dead-ends at exactly the records needing human judgment. + +Cost for UG: 16 languages × every English wording tweak = a steady drip of stale records, each costing a translator a YAML-archaeology session. The feature's value is capped by its resolution ergonomics. + +## Process + +TDD / red-green-refactor. Failing tests first, then implement, then refactor both surfaces onto shared plumbing: + +1. Core-adjacent: resolving a stale record via the CLI path rewrites the overlay dictionary correctly for both verbs — "confirm" promotes the existing translation under the new source text and removes the stale record; "replace" installs the new translation and removes the stale record. Assert exact canonical YAML output (goes through the canonical emitter, respects include preservation once that lands). +2. Resolution of a contextual stale record preserves its context key (the `Some("notes.note.finland")`-style case already covered in core tests must survive the CLI plumbing). +3. Batch confirm: an English change touching many notes with unchanged meaning can be confirmed in one command invocation; test a multi-record fixture. +4. Refusals: resolving a record that is not stale, or whose new source text doesn't match the current base, is a precise error, not a silent no-op (fail closed per ADR-0010). +5. Workbench: a stale record in the selected translation context is resolvable via an API endpoint for each verb; the write goes through the (queued) atomic-apply machinery and bumps the freshness generation; the next context fetch shows the record resolved. + +## Acceptance Criteria + +- `translations` CLI gains a resolve flow (subcommand or flags — match the existing CLI's conventions) offering confirm/replace, single-record and batch forms; the skip-warning path (~:1096) points users at it. +- Workbench API exposes stale resolution for the selected translation context (both verbs); UI affordance may be minimal (two actions on a stale row) and should land on whichever UI architecture is current — coordinate with `0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md` rather than building elaborate legacy-path UI. +- Both surfaces are thin adapters over `resolve_stale_translation` (and the existing overlay write/serialize plumbing) — no second resolution implementation. +- All dictionary writes produce canonical YAML byte-identical to what `fmt` would produce. +- `cargo test --workspace` passes; E2E extended with one stale-resolve happy path if the workbench UI affordance lands here. + +## Design Decisions + +- Sequencing: the CLI half has no dependencies — do it first. The Workbench half should follow the apply-atomicity task (`0120-make-workbench-apply-atomic-all-or-nothing.md`) and ride the UI migration rather than racing it. +- "Confirm" must NOT silently rewrite translations whose meaning plausibly changed — it is an explicit human verb, never an automatic rewrite (the current skip behavior stays the default for non-interactive apply). diff --git a/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md b/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md new file mode 100644 index 0000000..dfaa52f --- /dev/null +++ b/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md @@ -0,0 +1,40 @@ +--- +title: Add structured !image field references and an includable media block +priority: medium +--- + +## Goal + +Fields reference images structurally (`field.flag: !image ug-flag-zimbabwe.svg`) instead of embedding raw `` HTML, and the media declaration block can live in its own included file (`media: !include media.yaml`). Media references become exactly verifiable, the renderer owns image markup, and the deck source stops carrying ~1,600 lines of media boilerplate inline. + +## Problem + +- Image references are opaque HTML strings in field values. The media-integrity task's referenced-vs-declared verify check must regex-scrape `` out of content — workable but fragile, and the tool can never transform image markup (alt text, path rewriting) or diff image changes structurally. This mirrors the raw-string problem structured messages (ADR-0008 lineage) already solved for flag-similarity: same disease, same cure. +- Survey of the real UG repo (2026-07-03): 602 `` tags across deck + overlays, every single one exactly `` — no attributes, no variation. 221 flag fields + 319 map fields in `deck.yaml`; multi-image only in the hardcore extension (e.g. Bali: blur + normal pair). So a minimal `!image ` covers 100% of production usage, with a sequence form for the multi-image cases. +- `!include` is restricted to scalar content fields (`source_includes.rs` ~:101), so the 546-entry media block cannot be externalized from `deck.yaml` today. + +## Phase 1 — ADR (do this first, get it reviewed before implementing) + +Write ADR-00NN settling, with UG's surveyed usage as the evidence base: + +1. **Reference form**: `!image ` (path matches media declaration `path` and what's on disk — recommended, matches current HTML and stays greppable) vs `!image ` (rename-robust but indirect). Pick one, record why. +2. **Multi-image fields**: YAML sequence of tagged scalars (`field.flag: [!image a.png, !image b.png]` or block-sequence form) — confirm the canonical emitter/parser shape. +3. **Mixed text+image content**: out of scope unless the audit finds real usage (UG appears to have none — verify during the audit); raw HTML remains valid field content, `!image` is additive, not mandatory. +4. **Render contract**: at compose/export, `!image p` renders to exactly `` — byte-identical to today's UG output, so adoption is output-neutral and provable by export diff. +5. **Import reverse-mapping**: CrowdAnki import recognizes fields that are purely one-or-more `` tags and emits `!image` form; anything else stays raw HTML (safe fallback, not an error). +6. **Verify semantics**: `!image` references join the referenced-vs-declared check as exact matches (replacing the regex innards of the extraction function the media-integrity task isolates for this purpose). +7. **Includable media block**: lift the scalar-only `!include` restriction for whitelisted structural positions (at minimum `media:` in a deck) — NOT arbitrary mapping includes. Define interaction with the include-preserving fmt/verify work (tagged-scalar emission is shared machinery — coordinate with task `0070-make-fmt-and-format-verify-preserve-source-includes.md`) and with the `media hash` command (it must write hashes into the included file, through the include). + +## Phase 2 — Implementation + +- Parser + canonical emitter support for `!image` (scalar and sequence) in decks and overlays; compose/export rendering per the ADR; import reverse-mapping; verify exact-match integration. +- `media: !include` per the ADR, working through fmt (structure-preserving), verify, and `media hash`. +- Migration tooling: a one-shot command or script converting pure-`` fields to `!image` (UG has ~600 conversions; must be mechanical and output-neutral). +- Tests: round-trip (parse/emit/idempotence) for both new forms; export byte-equivalence on a fixture before/after migration; import reverse-map; verify exact matching; multi-image sequence case. +- `cargo test --workspace` passes; docs updated (authoring docs + yaml reference). + +## Design Decisions + +- Sequence AFTER the media-integrity task (which works with regex extraction and is not blocked on this) and coordinate with the include-preserving fmt task (shared tagged-scalar emission). +- Adoption in UG is a separate UG-side task to file once this ships (run the migration, externalize the media block, verify output-neutrality by export diff). +- The includable-media-block half (#7) is severable: if it turns out to fight the include-preservation work, split it out rather than stalling `!image`. diff --git a/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md b/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md new file mode 100644 index 0000000..e1a66c8 --- /dev/null +++ b/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md @@ -0,0 +1,38 @@ +--- +title: Add HTML/CSS content validation to verify +priority: medium +--- + +## Goal + +`verify` validates the HTML and CSS content a deck ships to Anki — template fragments, deck descriptions, styling — natively in Rust, wherever that content lives (inline or `!include`d). Brain Brew is an Anki deck tool; HTML is always part of the product, so the tool owns its validation. UG's Python side-script becomes deletable. + +## Problem + +`verify` checks YAML structure, translations, and media, but never looks inside template/description/styling content. A template with a mismatched tag or a stylesheet with an unbalanced brace passes verify and export, then fails at render time in Anki on a user's device. UG plugged the hole with `scripts/check-source-content.py` (~150 lines of Python: HTML fragment well-formedness via `html.parser`, CSS brace/comment/string balance), wired into `integrity-check.yml:26` and CONTRIBUTING: + +- Protection is UG-only and layout-coupled: the script scans three hardcoded directories (`descriptions/`, `templates/`, `styles/`), so other consumers get nothing and UG's INLINE HTML (field values, non-externalized content) is invisible to it. +- It drags a Python runtime into otherwise pure-Rust/Nix CI — the out-of-band-script pattern this rewrite exists to end. +- It runs too late: CI-time only. The workbench will happily APPLY a malformed template edit today. + +## Process + +Parity first, then port: + +1. Capture the Python script's actual tolerances as a test corpus BEFORE writing Rust: run it against UG's real templates/descriptions/styles plus deliberately broken variants (mismatched tag, unclosed tag, stray ``, unbalanced brace, unterminated comment/string in CSS, `{{Field}}` and `{{cloze:...}}` mustache, void elements like `
`/``, HTML entities). Record accept/reject for each — the Rust check must not silently reject content the script blessed or accept what it caught. +2. Implement the checks in Rust against that corpus (red-green), as a pure function over content strings in core or formats — no filesystem knowledge. +3. Wire into `verify` over the COMPOSED deck (covers inline and included content uniformly, per target), and expose the same function to the workbench apply path so malformed template edits are rejected at write time. + +## Acceptance Criteria + +- A `verify` sub-check validates, for every target: note-model templates (question/answer), deck descriptions, and styling — reporting file/path-precise errors (which template, which target, what's wrong). +- Checks match the side-script's lightweight ambition (this is NOT a spec-grade HTML validator): fragment tag balance tolerant of Anki mustache syntax and void elements; CSS brace/comment/string balance. No heavyweight parser dependency (no html5ever) unless the parity corpus forces it. +- Hand-rolled in Rust, zero Python anywhere; pure function reusable by the workbench apply path (wiring apply-side rejection may land here or ride the apply-atomicity task — either way the function is shared, not duplicated). +- The parity corpus lives in the repo as table-driven tests. +- An escape hatch exists for false positives (per-check severity or an opt-out flag), documented — Anki tolerates a lot of sloppy HTML; the check must not block a deck Anki renders fine without a way out. +- `cargo test --workspace` passes; verify docs updated. + +## Design Decisions + +- Scope creep guard: no CSS property validation, no HTML attribute/semantics linting, no external references — structural well-formedness only, matching what the Python script proved useful. +- UG-side deletion of the script is a separate task in the UG repo, triggered on the next Brain Brew publish. diff --git a/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md b/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md new file mode 100644 index 0000000..096df2b --- /dev/null +++ b/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md @@ -0,0 +1,29 @@ +--- +title: Replace fixed-sleep E2E assertions with condition polling +priority: low +--- + +## Goal + +No E2E assertion depends on wall-clock timing: the loading/stale-state scenario polls for observable conditions using the suite's existing `wait_for_*` helpers, per ADR-0014. + +## Problem + +`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs` ~:406 and ~:441 inject an artificial 800ms delay into a server response (to exercise the workbench loading/stale-state UI), then do a fixed 950ms sleep before asserting the UI has settled: + +- It races with a 150ms margin: the assertion holds only if the delayed response + render round-trip completes within 150ms of the injected delay expiring. CI load, WebDriver latency, or WASM startup jitter eats that margin → unreproducible flake. +- It is slow even when passing: every run pays the full 950ms unconditionally, twice. +- It violates ADR-0014 in a file whose own conventions (`wait_for_*` polling helpers, used everywhere else) show the rule is known — a standing bad example for the next test author to copy. + +## Acceptance Criteria + +- Both fixed sleeps are gone; the scenario polls for observable conditions with the suite's standard `wait_for_*` helpers and timeout: + - If the scenario must assert the intermediate state (loading/stale indicator visible during the delay), poll for that state FIRST, then poll for the settled state — both are conditions, neither needs a clock. +- No new fixed sleeps anywhere in the E2E crate; a quick sweep confirms these two were the only wall-clock assertions (fix any others found the same way). +- Consider shrinking the 800ms injected delay once nothing sleeps against it — keep it just large enough that the intermediate-state poll can reliably observe the loading state; note the chosen value. +- The scenario still meaningfully exercises the loading/stale-state UI (do not delete the intermediate-state assertion to make polling trivial). +- E2E suite passes (BRAINBREW_E2E_* env per existing docs); `cargo test --workspace` unaffected. + +## Design Decisions + +- Test-only change; no production code, no server changes beyond (optionally) the injected-delay parameter the test already controls. diff --git a/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md b/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md new file mode 100644 index 0000000..757f966 --- /dev/null +++ b/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md @@ -0,0 +1,23 @@ +--- +title: Fix stale target count in the ultimate-geography example docs +priority: low +--- + +## Goal + +The ultimate-geography worked example in the docs reflects the real UG repo, and no longer hardcodes a target count that rots as UG grows. + +## Problem + +`documentation/docs/examples/ultimate-geography.md` still claims "56 verified targets" (line 27) and shows `✓ verified 56 targets` sample output (line 44). The real UG repo now builds roughly 80 targets across its two manifests (`brainbrew.yaml` + `brainbrew-hardcore.yaml`, 16 languages including the later-added Hebrew). This page is the only real-world example in the docs — its whole purpose is to be a trustworthy worked example, and stale numbers on it signal "the docs aren't maintained". + +## Acceptance Criteria + +- Re-verify the current numbers first against the live repo (`~/Development/external/ultimate-geography`, or its upstream): run `verify --all-targets` / `targets` per manifest and use the actual counts. Do not trust the "~80" in this task file. +- Update the prose and the sample output. De-specify where possible so the page can't rot the same way again — e.g. "all language targets across both manifests" in prose, and mark the sample-output count as illustrative (or regenerate it as part of the edit and date it). +- While on the page, sweep it for other stale facts against the real repo (language count, manifest names, layout paths — the repo reorganized to `templates//{question,answer}.html`); fix what's cheap, note anything bigger as a follow-up rather than expanding scope. +- Docs build passes (whatever `documentation/` uses to build/lint). + +## Design Decisions + +- Docs-only change; no code, no fixtures. Fixture drift vs the real repo is a separate concern. diff --git a/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md b/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md new file mode 100644 index 0000000..e97360a --- /dev/null +++ b/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md @@ -0,0 +1,28 @@ +--- +title: Mark the lock/package federation surface experimental before the next publish +priority: medium +--- + +## Goal + +The lock/package subsystem (`brainbrew lock`, `FederationLock`, downstream deck packages) is honestly labeled **experimental** across docs and CLI, and explicitly excluded from the stability promise of the upcoming crates.io release — without deleting or destabilizing any of the working, tested code. + +## Problem + +The lock/package federation machinery is fully built and tested (`crates/brain-brew-cli/src/commands/lock.rs` ~885 lines, `crates/brain-brew-formats/src/lockfile.rs` ~238 lines; remote package fetch, gzipped NAR unpack, NAR-style hash pinning; ~41 references in the CLI test suite) and prominently documented (`documentation/docs/reference/lockfile.md`, `examples/downstream-package.md`, featured in `intro.md` and `concepts/what-is-federation.md`). It has **zero real consumers**: ultimate-geography — the only production deck — has no lock file; no downstream package exists anywhere. + +Publishing the next version with this surface presented as a stable pillar freezes a lockfile format and CLI contract that no real usage has pressure-tested. The decision (2026-07-03 review, issue 20) was to **relabel**, not trim: keep the code and the vision, drop the implicit stability commitment. + +## Acceptance Criteria + +- Every doc page presenting the lock/package feature (`reference/lockfile.md`, `examples/downstream-package.md`, `intro.md`, `concepts/what-is-federation.md`, plus mentions in `getting-started/cli-tour.md`, `reference/project-scope.md`, `reference/releasing.md` — re-grep for the full list, don't trust this one) carries a clear, consistent experimental notice: the feature works, but the lockfile format and `lock` CLI surface may change incompatibly in any release until a real downstream consumer stabilizes them. +- `brainbrew lock --help` (and any subcommand help text) states the experimental status in one line. +- The stability/versioning story is stated once, centrally (`releasing.md` or wherever the release policy lives): what IS covered by the next release's compatibility promise (canonical YAML format, deck/overlay semantics, core CLI verbs) and that lock/package is explicitly outside it. +- No code behavior changes; `cargo test --workspace` still passes untouched. +- Zero deletions: all lock/package code, tests, and docs remain — this is labeling, not trimming. + +## Design Decisions + +- Sequencing: MUST land before the next crates.io publish (the whole point is what that release promises). Pairs with the UG-side version-pinning task only in timing, not content. +- Tombstone/patch/personal overlay kinds are NOT part of this task — they were reviewed and judged cheap, coherent parts of the overlay algebra needing no relabel (review issue 20, tiers 2–3). +- If a real downstream consumer appears later, removing the experimental notices is the closing act of whatever task onboards it. diff --git a/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md b/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md new file mode 100644 index 0000000..d2950bb --- /dev/null +++ b/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md @@ -0,0 +1,32 @@ +--- +title: Add a cross-language matrix view to the Workbench +priority: medium +--- + +## Goal + +For a selected note (or source string), the Workbench shows all languages side by side in one table — resolved value plus translation status per language — restoring the cross-language visibility the old CSV world had for free (one row per fact, one column per language) and the overlay architecture deliberately traded away without a replacement. + +## Problem + +All three workbench pivots (`note-pivot`, `source-string-pivot`, `card-pivot` — `workbench.rs` ~:1302-1318) take a single `language`/`target`/`overlay` selection; nothing in the tool shows one note across languages. Comparing 16 languages of `field.capital` for one note today means opening 16 overlay files or re-selecting the workbench context 16 times. Daily UG moments this blocks: + +- Reviewing a new-language PR (e.g. Hebrew) next to sibling languages. +- After an English source edit: which of the 16 languages are resolved vs still stale vs missing — an inherently cross-language question the tool can only answer one language at a time. +- Terminology-consistency checks across related languages (da/no/sv, es/pt). + +## Acceptance Criteria + +- A workbench API endpoint returning, for a selected note (and optionally a single field or source string), a matrix: one row per language target, with the composed/resolved value and a status per cell (translated / stale / missing / no_change — reuse the existing coverage categories, don't invent a parallel taxonomy). +- The matrix is built from the SAME resolution logic compose/verify use (via the unified resolver once `0050-unify-translation-resolution-into-single-resolver.md` lands) — what the matrix says must be what ships. +- Composing all language targets per request rides the generation-keyed composition cache (`0030-cache-workspace-composition-in-workbench-server.md` is a hard dependency — without it this view is ~16 full compositions per click). +- A workbench UI panel renders the matrix with staleness/missing visually flagged; built on the migrated declarative UI (`0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md`), not as new `format!`-HTML on the legacy path. +- Optional/severable: a CLI twin (e.g. `translations matrix --note X [--field Y]`) emitting the same data as aligned text or TSV for terminal-based PR review. +- Integration test on the UG fixture: matrix for one note returns a row per language with correct statuses (include a stale and a missing case); E2E covers rendering one matrix. +- `cargo test --workspace` passes. + +## Design Decisions + +- Read-only view in this task. Editing cells in place, or resolving stale records from the matrix, are natural follow-ups that ride the stale-resolution task (`0140-wire-stale-translation-resolution-into-cli-and-workbench.md`) — link them, don't build them here. +- Sequencing: after the composition cache (hard), after/with the UI migration (strong preference), after the resolver unification (correctness of the status column). +- Row set = language targets from the manifest; the view must not hardcode UG's language list or assume every language covers every note (missing IS a status, not an error). diff --git a/.frontloop/quality-docs-observability/done/0001-limit-default-rust-test-and-build-parallelism.md b/.frontloop/quality-docs-observability/done/0001-limit-default-rust-test-and-build-parallelism.md new file mode 100644 index 0000000..14c8964 --- /dev/null +++ b/.frontloop/quality-docs-observability/done/0001-limit-default-rust-test-and-build-parallelism.md @@ -0,0 +1,43 @@ +--- +title: Limit default Rust test and build parallelism +priority: critical +--- + +## Goal + +Make Brain Brew's default Devenv test/build commands CPU-friendly so the heavy Ultimate Geography regression tests do not saturate the machine, accepting longer wall-clock time. + +## Acceptance Criteria + +- `devenv shell test` runs Rust test cases with a default maximum of two concurrent libtest threads +- Cargo compilation invoked through the project environment defaults to at most two jobs +- The limits apply consistently to CI-style, development-write, focused, and E2E Rust test entrypoints unless an explicit documented override is supplied +- A regression check demonstrates the effective defaults inside `devenv shell` +- Developer documentation explains the thermal-friendly defaults and how to intentionally override them +- Full test, clippy, and representative E2E gates remain green under the limited defaults + +## Design Decisions + +- Use a conservative default of 2 for both Rust test threads and Cargo build jobs +- Keep explicit per-invocation overrides available for CI or developers who want more throughput + +## Implementation Notes + +Urgent user-requested thermal-safety task. Integrate before resuming the larger remediation queue. + + +## Completion Summary + +- Added runtime Devenv defaults of RUST_TEST_THREADS=2 and CARGO_BUILD_JOBS=2 +- Applied the same fallback in enterShell and enterTest so named tasks, focused commands, dev-write tests, clippy/builds, Trunk/E2E, release smoke, and arbitrary Devenv Cargo commands inherit it +- Preserved explicit caller overrides through shell fallback semantics +- Added check:rust-parallelism using real nested Devenv shells to prove defaults and overrides and wired it into ci/devenv test +- Documented cooler but longer runs, overrides, and scope in AGENTS, README, and install docs +- Passed full CI under bounded defaults, including heavy UG regressions and 13 browser E2E, plus independent Claude judgment + +### Files Changed + +- devenv.nix +- AGENTS.md +- README.md +- documentation/docs/getting-started/install.md diff --git a/.frontloop/quality-docs-observability/done/choose-public-rust-crate-support-commitment.md b/.frontloop/quality-docs-observability/done/choose-public-rust-crate-support-commitment.md new file mode 100644 index 0000000..ce444ab --- /dev/null +++ b/.frontloop/quality-docs-observability/done/choose-public-rust-crate-support-commitment.md @@ -0,0 +1,23 @@ +--- +title: Choose public Rust crate support commitment +priority: medium +--- + +## Goal + +Decide whether core/formats are supported semver interfaces with examples and compatibility commitments or implementation crates published only to support the CLI. + +## Acceptance Criteria + +- Choose supported-public or implementation-package status +- Define semver/documentation expectations +- Align crates.io descriptions and repository documentation +- Identify required consumer tests/examples if public + +## Implementation Notes + +Does not block correctness work. + +## Questions + +### Q1: Recommended: treat core/formats as preview public interfaces only after extracted-package examples and semver policy exist; until then label them implementation packages. Approve or choose immediate public support? diff --git a/.frontloop/quality-docs-observability/epic.md b/.frontloop/quality-docs-observability/epic.md new file mode 100644 index 0000000..eb5042b --- /dev/null +++ b/.frontloop/quality-docs-observability/epic.md @@ -0,0 +1,15 @@ +--- +title: Quality gates, documentation, and observability +slug: quality-docs-observability +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Replace happy-path confidence with executable documentation, adversarial/property/fuzz testing, cross-platform artifact checks, accessibility coverage, performance budgets, and accurate architecture/schema records. + +## Sequence + +Make user-facing examples executable and records truthful, add generated/adversarial test infrastructure, then mutation/fuzz/cross-platform/performance gates and decide the public Rust interface commitment. diff --git a/.frontloop/quality-docs-observability/ready/0010-supersede-stale-workbench-adrs-and-update-architecture-maps.md b/.frontloop/quality-docs-observability/ready/0010-supersede-stale-workbench-adrs-and-update-architecture-maps.md new file mode 100644 index 0000000..c7bc7c5 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0010-supersede-stale-workbench-adrs-and-update-architecture-maps.md @@ -0,0 +1,20 @@ +--- +title: Supersede stale Workbench ADRs and update architecture maps +priority: high +--- + +## Goal + +Make accepted decisions describe Leptos, the five-crate workspace, current list/detail progress, safety posture, and stable versus internal interfaces. + +## Acceptance Criteria + +- ADR-0011 and ADR-0014 are superseded or amended from Iced to Leptos +- ADR-0015 records incomplete and completed detail migration accurately +- Project scope maps all crates and dependency direction +- Safety/transaction/trust decisions are linked when resolved +- Decision index distinguishes active, superseded, and experimental records + +## Implementation Notes + +Can land early for truthfulness, then update as hardening decisions resolve. diff --git a/.frontloop/quality-docs-observability/ready/0020-complete-yaml-cli-recovery-and-workbench-reference-documentation.md b/.frontloop/quality-docs-observability/ready/0020-complete-yaml-cli-recovery-and-workbench-reference-documentation.md new file mode 100644 index 0000000..fc7d0ee --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0020-complete-yaml-cli-recovery-and-workbench-reference-documentation.md @@ -0,0 +1,20 @@ +--- +title: Complete YAML CLI recovery and Workbench reference documentation +priority: high +--- + +## Goal + +Document the actual schema and supported workflows, including variables, sparse overlays, manifests, locks, stable IDs, include preservation, stale resolution, recovery, and API maturity. + +## Acceptance Criteria + +- YAML reference covers all canonical/overlay/manifest/lock fields and stable-ID grammar +- Include materialization wording matches tested behavior +- CLI reference reflects required options and JSON contracts +- Recovery docs cover lock update, source transactions, Workbench drafts/conflicts, and release gates +- Legacy Iced and broad-pivot descriptions are removed + +## Implementation Notes + +Generate schema tables where practical to prevent drift. diff --git a/.frontloop/quality-docs-observability/ready/0030-add-a-tested-workspace-initialization-workflow.md b/.frontloop/quality-docs-observability/ready/0030-add-a-tested-workspace-initialization-workflow.md new file mode 100644 index 0000000..05b60c7 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0030-add-a-tested-workspace-initialization-workflow.md @@ -0,0 +1,20 @@ +--- +title: Add a tested workspace initialization workflow +priority: high +--- + +## Goal + +Provide an `init` command or an explicitly supported generated scaffold so a new maintainer can create a valid manifest, deck, directories, and first target without hand-assembling undocumented state. + +## Acceptance Criteria + +- The workflow creates all required directories and canonical starter files +- Generated workspace passes format and verify immediately +- Options cover a minimal local deck and clearly defer advanced federation +- Existing destinations require explicit safe overwrite behavior +- CLI and documentation tests exercise initialization from an empty directory + +## Implementation Notes + +Land before executable quickstart so the quickstart uses the supported scaffold. diff --git a/.frontloop/quality-docs-observability/ready/0040-make-quickstart-import-and-installation-examples-executable-in-ci.md b/.frontloop/quality-docs-observability/ready/0040-make-quickstart-import-and-installation-examples-executable-in-ci.md new file mode 100644 index 0000000..246f72b --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0040-make-quickstart-import-and-installation-examples-executable-in-ci.md @@ -0,0 +1,20 @@ +--- +title: Make quickstart import and installation examples executable in CI +priority: high +--- + +## Goal + +Turn user documentation into tested workflows that create directories, use canonical YAML, pass required flags, and install the documented CLI. + +## Acceptance Criteria + +- Quickstart runs from an empty temporary directory to verified/exported output +- Import examples include the reviewed ID workflow and safe output behavior +- Every advertised install command is smoke-tested or removed +- Command snippets are extracted/executed to prevent drift +- Failures report the documentation page and snippet + +## Implementation Notes + +Update behavior only after relevant CLI/release tasks land; scaffold the harness first. diff --git a/.frontloop/quality-docs-observability/ready/0050-add-property-and-generated-law-tests-for-core-and-formats.md b/.frontloop/quality-docs-observability/ready/0050-add-property-and-generated-law-tests-for-core-and-formats.md new file mode 100644 index 0000000..badcf61 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0050-add-property-and-generated-law-tests-for-core-and-formats.md @@ -0,0 +1,20 @@ +--- +title: Add property and generated law tests for core and formats +priority: high +--- + +## Goal + +Cover invariant combinations beyond hand-picked examples for composition, translation commands, canonical round trips, semantic diff, and stable ordering. + +## Acceptance Criteria + +- Generated tests exercise intent/value/entity matrices +- Canonical decode-encode-decode and format-idempotence laws include hostile scalars and maps +- Translation command sequences preserve cross-map invariants +- Semantic diff mutation laws observe every property +- Seeds and minimized regressions are deterministic in CI + +## Implementation Notes + +Add after the corresponding corrected interfaces stabilize; individual behavior tasks still follow red-green-refactor. diff --git a/.frontloop/quality-docs-observability/ready/0060-add-fuzz-and-panic-resistance-targets-for-format-and-adapter-inputs.md b/.frontloop/quality-docs-observability/ready/0060-add-fuzz-and-panic-resistance-targets-for-format-and-adapter-inputs.md new file mode 100644 index 0000000..ec6eb78 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0060-add-fuzz-and-panic-resistance-targets-for-format-and-adapter-inputs.md @@ -0,0 +1,20 @@ +--- +title: Add fuzz and panic-resistance targets for format and adapter inputs +priority: medium +--- + +## Goal + +Continuously exercise YAML, include, manifest, lock, media, and CrowdAnki decoders/emitters against malformed data without panic, hang, or silent acceptance. + +## Acceptance Criteria + +- Fuzz targets cover canonical YAML, overlays, manifests, locks, includes, media maps, and CrowdAnki JSON +- Direct emitters are tested against constructible invalid domain values +- Crash/hang/silent-loss findings become minimized regression fixtures +- Scheduled CI runs bounded fuzz campaigns and stores useful artifacts +- Resource limits keep adversarial inputs bounded + +## Implementation Notes + +After strict decoder work; do not substitute fuzzing for targeted TDD. diff --git a/.frontloop/quality-docs-observability/ready/0070-introduce-mutation-testing-for-critical-fail-closed-invariants.md b/.frontloop/quality-docs-observability/ready/0070-introduce-mutation-testing-for-critical-fail-closed-invariants.md new file mode 100644 index 0000000..ddc2521 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0070-introduce-mutation-testing-for-critical-fail-closed-invariants.md @@ -0,0 +1,20 @@ +--- +title: Introduce mutation testing for critical fail-closed invariants +priority: medium +--- + +## Goal + +Measure whether tests detect removed duplicate checks, containment checks, expected-base comparisons, CAS checks, media verification, and golden enforcement. + +## Acceptance Criteria + +- A bounded mutation configuration targets critical modules first +- Named mutations for each release-blocking invariant are killed +- Surviving mutants produce follow-up tests or documented exclusions +- Runtime is suitable for scheduled CI and focused local use +- A baseline score and non-regression threshold are recorded + +## Implementation Notes + +After critical regression suites land. diff --git a/.frontloop/quality-docs-observability/ready/0080-add-cross-platform-artifact-and-interruption-smoke-tests.md b/.frontloop/quality-docs-observability/ready/0080-add-cross-platform-artifact-and-interruption-smoke-tests.md new file mode 100644 index 0000000..e443990 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0080-add-cross-platform-artifact-and-interruption-smoke-tests.md @@ -0,0 +1,20 @@ +--- +title: Add cross-platform artifact and interruption smoke tests +priority: medium +--- + +## Goal + +Exercise package install, path handling, rename/recovery behavior, and representative CLI flows on Linux, macOS, Windows, and the declared ARM policy. + +## Acceptance Criteria + +- Produced artifacts install and report the expected version on each supported platform +- Path and replacement tests cover Windows and cross-filesystem behavior +- Interrupted transaction recovery is exercised where runners permit +- Unsupported ARM targets are explicitly documented or tested +- Pre-tag workflow runs the supported platform matrix before publication + +## Implementation Notes + +Coordinate release-baseline and transaction modules. diff --git a/.frontloop/quality-docs-observability/ready/0090-establish-accessibility-and-performance-regression-budgets.md b/.frontloop/quality-docs-observability/ready/0090-establish-accessibility-and-performance-regression-budgets.md new file mode 100644 index 0000000..3b3d65a --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0090-establish-accessibility-and-performance-regression-budgets.md @@ -0,0 +1,20 @@ +--- +title: Establish accessibility and performance regression budgets +priority: medium +--- + +## Goal + +Turn Workbench accessibility, target verification, media, package discovery, and cache measurements into repeatable quality gates. + +## Acceptance Criteria + +- Keyboard/screen-reader semantics are tested for all Workbench workflows +- Budgets cover 74-target verify, cold/warm media, 93 selections, package discovery, and large media sets +- Benchmarks report CPU, wall time, RSS, reads, and payload sizes where relevant +- Thresholds are based on reproducible fixtures and allow intentional reviewed updates +- Scheduled reports distinguish correctness failures from performance regressions + +## Implementation Notes + +After architecture/performance fixes define expected baselines. diff --git a/.frontloop/quality-docs-observability/ready/0100-add-or-remove-supported-rust-consumer-examples.md b/.frontloop/quality-docs-observability/ready/0100-add-or-remove-supported-rust-consumer-examples.md new file mode 100644 index 0000000..2a78520 --- /dev/null +++ b/.frontloop/quality-docs-observability/ready/0100-add-or-remove-supported-rust-consumer-examples.md @@ -0,0 +1,19 @@ +--- +title: Add or remove supported Rust consumer examples +priority: low +--- + +## Goal + +Implement the approved crate-support decision with compile-tested examples and stability notes, or reclassify package metadata to avoid unsupported reusable-interface claims. + +## Acceptance Criteria + +- Crate metadata and docs match the support decision +- If public, examples consume packaged crates outside the workspace and run in CI +- If internal, reusable-interface marketing and unsupported promises are removed +- Versioning and deprecation expectations are explicit + +## Implementation Notes + +Depends on public Rust crate support clarification and release package verification. diff --git a/.frontloop/release-baseline/clarify/publish-and-verify-only-supported-installation-channels.md b/.frontloop/release-baseline/clarify/publish-and-verify-only-supported-installation-channels.md new file mode 100644 index 0000000..4778347 --- /dev/null +++ b/.frontloop/release-baseline/clarify/publish-and-verify-only-supported-installation-channels.md @@ -0,0 +1,24 @@ +--- +title: Publish and verify only supported installation channels +priority: high +--- + +## Goal + +Make README and installation documentation point exclusively to live, tested artifacts matching the documented CLI interface. + +## Acceptance Criteria + +- Every advertised tag URL, installer, crate, formula, or Nix command resolves successfully +- Each supported channel installs the same version and passes a CLI feature smoke test +- Unavailable Homebrew/GitHub/Nix claims are removed or clearly marked planned +- Release-channel checks run after publication and report actionable failures + +## Implementation Notes + +Last release-baseline task; depends on selected channel policy and green artifact gates. + + +## Blocked + +Final acceptance requires real alpha.2 publication and post-publication verification of advertised crates.io, GitHub artifact/tag, and pinned Nix channels. Publishing is an external irreversible action that has not been explicitly requested; current release policy retains manual crates.io publication and live Ultimate Geography consumer evidence is deliberately still absent, which blocks tagged hosting fail-closed. The task can resume after an explicit publish/verification request and required live-consumer evidence configuration. diff --git a/.frontloop/release-baseline/done/0010-bump-and-synchronize-all-publishable-crate-versions.md b/.frontloop/release-baseline/done/0010-bump-and-synchronize-all-publishable-crate-versions.md new file mode 100644 index 0000000..b3085f9 --- /dev/null +++ b/.frontloop/release-baseline/done/0010-bump-and-synchronize-all-publishable-crate-versions.md @@ -0,0 +1,50 @@ +--- +title: Bump and synchronize all publishable crate versions +priority: critical +--- + +## Goal + +Apply the approved preview version across workspace metadata, exact internal dependencies, lock data, changelog, and release configuration so no source claims the immutable alpha.1 interface. + +## Acceptance Criteria + +- All publishable crates and exact internal dependencies use the approved version +- Cargo.lock, changelog, dist configuration, and user-facing version references agree +- No stale alpha.1 source/interface claims remain outside historical records +- Workspace tests and metadata validation pass + +## Design Decisions + +- Keep publishable crates versioned in lockstep unless the release-policy task explicitly decides otherwise + +## Implementation Notes + +Prerequisite: clarify/choose-next-preview-version-and-supported-release-channels. + + +## Completion Summary + +- Synchronized all publishable workspace crates and exact internal dependency constraints at 1.0.0-alpha.2 +- Updated Cargo.lock, release metadata, cargo-dist configuration, GitHub release workflow, changelog, scripts, docs, README, and Nix install references +- Added deterministic release-version validation covering manifests, internal pins, lock data, derived dist/flake version sources, docs, and bounded historical alpha.1 allowlist +- Recorded crates.io as an explicit later manual publication step and labeled core/formats as implementation packages +- Removed current Homebrew claims; retained alpha.1 only as an accurate historical record +- Passed metadata validation, full tests, fmt, clippy, docs, cargo-dist plan, core package dry-run, release dry-run/smoke, Nix flake evaluation, Nix version derivation, and Claude judgment + +### Files Changed + +- Cargo.toml +- Cargo.lock +- crates/brain-brew-core/Cargo.toml +- crates/brain-brew-formats/Cargo.toml +- .github/workflows/release.yml +- dist-workspace.toml +- devenv.nix +- scripts/check_release_version.py +- scripts/publish_crates.sh +- CHANGELOG.md +- README.md +- documentation/docs/getting-started/install.md +- documentation/docs/reference/releasing.md +- crates/brain-brew-cli/tests/registry_planner.rs diff --git a/.frontloop/release-baseline/done/0020-verify-extracted-crates-in-registry-dependency-order.md b/.frontloop/release-baseline/done/0020-verify-extracted-crates-in-registry-dependency-order.md new file mode 100644 index 0000000..2218235 --- /dev/null +++ b/.frontloop/release-baseline/done/0020-verify-extracted-crates-in-registry-dependency-order.md @@ -0,0 +1,51 @@ +--- +title: Verify extracted crates in registry dependency order +priority: critical +--- + +## Goal + +Replace path-only confidence with a release gate that packages and verifies the exact `.crate` artifacts in core → formats → CLI order against the intended dependency versions. + +## Acceptance Criteria + +- The gate builds extracted crate contents rather than workspace paths +- Core, formats, and CLI are verified in publication order +- The alpha.1 published-core mismatch is covered by a regression test or scripted fixture +- Package archives contain required README and license material +- The gate fails before any upload when an internal interface/version is inconsistent + +## Implementation Notes + +Run after the version synchronization task; integrate with scripts/publish_crates.sh. + + +## Completion Summary + +- Added real cargo-package archive verification for core, formats, and CLI in publication order +- Validated extracted archive README/license material and packaged relative links +- Stripped/rejected internal path-dependency leakage and compiled only extracted artifacts against checksum-verified staged Cargo directory sources +- Added a real changed-core interface regression proving dependent extracted builds fail rather than accepting alpha.1-like mismatch +- Added separate pre-publish staged-artifact coherence and indexed-registry readiness modes; blocked states are nonzero rather than skipped success +- Integrated the pre-publish gate before every publish path and retained manual --yes publication requirement +- Documented alpha.2 manual core→index→formats→index→CLI sequencing and evidence boundaries +- Passed extracted gate/fixture tests, full Rust tests, metadata/fmt/clippy/docs/release smoke/dist plan, and Claude judgment + +### Files Changed + +- scripts/verify_extracted_crates.py +- scripts/tests/test_verify_extracted_crates.py +- scripts/publish_crates.sh +- scripts/check_cratesio_metadata.py +- devenv.nix +- .github/workflows/package-smoke.yml +- documentation/docs/reference/releasing.md +- crates/brain-brew-core/Cargo.toml +- crates/brain-brew-core/README.md +- crates/brain-brew-core/LICENSE +- crates/brain-brew-formats/Cargo.toml +- crates/brain-brew-formats/README.md +- crates/brain-brew-formats/LICENSE +- crates/brain-brew-cli/Cargo.toml +- crates/brain-brew-cli/README.md +- crates/brain-brew-cli/LICENSE diff --git a/.frontloop/release-baseline/done/0030-separate-nix-package-checks-from-prepared-browser-e2e.md b/.frontloop/release-baseline/done/0030-separate-nix-package-checks-from-prepared-browser-e2e.md new file mode 100644 index 0000000..d758e7f --- /dev/null +++ b/.frontloop/release-baseline/done/0030-separate-nix-package-checks-from-prepared-browser-e2e.md @@ -0,0 +1,43 @@ +--- +title: Separate Nix package checks from prepared browser E2E +priority: critical +--- + +## Goal + +Make `nix build` produce and test the CLI deterministically without invoking an unprepared WebDriver harness, while retaining a distinct prepared browser gate. + +## Acceptance Criteria + +- The Nix package check builds the CLI and non-browser tests successfully +- Browser E2E runs only in a derivation/job that provisions the CLI, UI assets, browser, and driver +- `nix build .#checks.x86_64-linux.brainbrew` passes +- CI retains all 13 browser scenarios in the prepared gate + +## Implementation Notes + +Can proceed in parallel with extracted-crate verification after version synchronization. + + +## Completion Summary + +- Separated real Nix CLI package testing from browser WebDriver E2E by explicit Cargo package ownership +- Made `nix build .#checks.x86_64-linux.brainbrew` build the real CLI and test exactly non-browser packages +- Made `nix run .#brainbrew` execute the Nix-built artifact +- Added prepared Linux Devenv E2E script that builds fresh UI/assets and write-enabled CLI, provisions Chromium/chromedriver, and runs all 13 scenarios +- Made Nix package and prepared browser E2E required CI/release quality gates +- Added partition regression checks and documented platform/recovery boundaries +- Passed real Nix build/run smoke, 13 E2E scenarios, full tests, fmt/clippy, docs, release smoke, workflow/flake validation, and Claude judgment + +### Files Changed + +- flake.nix +- devenv.nix +- scripts/run_workbench_e2e.sh +- scripts/tests/test_nix_e2e_partition.py +- .github/workflows/ci.yml +- .github/workflows/release.yml +- crates/brain-brew-cli/tests/lock_cli.rs +- documentation/docs/getting-started/install.md +- documentation/docs/reference/releasing.md +- documentation/docs/reference/workbench.md diff --git a/.frontloop/release-baseline/done/0040-gate-every-release-on-reusable-quality-and-artifact-workflows.md b/.frontloop/release-baseline/done/0040-gate-every-release-on-reusable-quality-and-artifact-workflows.md new file mode 100644 index 0000000..3fde309 --- /dev/null +++ b/.frontloop/release-baseline/done/0040-gate-every-release-on-reusable-quality-and-artifact-workflows.md @@ -0,0 +1,45 @@ +--- +title: Gate every release on reusable quality and artifact workflows +priority: critical +--- + +## Goal + +Prevent cargo-dist hosting or channel publication unless CI, extracted-package verification, package smoke, Nix checks, embedded assets, docs, and representative consumer gates are green for the same commit. + +## Acceptance Criteria + +- Release jobs depend on one reusable quality workflow for the same SHA +- Package and artifact smoke install from produced artifacts, not source paths +- No tag can reach upload/host after a required gate fails or is skipped +- The workflow is exercised safely on pull requests without publication credentials + +## Implementation Notes + +Depends on extracted-crate and Nix gate tasks; UG consumer gate is added by its epic. + + +## Completion Summary + +- Added SHA-bound reusable quality workflow shared by CI and tagged release workflows +- Required immutable SHA validation, artifact evidence, Rust quality, extracted crates, Nix package, prepared E2E, docs/embed, package/archive smoke, and consumer-evidence contract +- Made artifact smoke install/package only real Cargo and cargo-dist artifacts with checksum/provenance validation +- Made every release plan/build/host path hard-depend on same-SHA quality success with no always/skip bypass +- Kept pull requests credential-free and side-effect-free while exercising the reusable workflow +- Added fail-closed representative live-consumer interface; fixture evidence cannot satisfy release hosting +- Added workflow/static gate regression tests and documentation +- Passed workflow tests, full suite, fmt/clippy, artifact/archive smoke, Nix check, 13 E2E, docs, and Claude judgment + +### Files Changed + +- .github/workflows/reusable-quality.yml +- .github/workflows/ci.yml +- .github/workflows/release.yml +- .github/workflows/package-smoke.yml +- scripts/package_artifact_smoke.py +- scripts/smoke_release_archive.py +- scripts/verify_representative_consumer.py +- scripts/tests/test_release_gate_workflows.py +- scripts/tests/test_nix_e2e_partition.py +- devenv.nix +- documentation/docs/reference/releasing.md diff --git a/.frontloop/release-baseline/done/0050-pin-release-actions-installers-and-least-privilege-credentials.md b/.frontloop/release-baseline/done/0050-pin-release-actions-installers-and-least-privilege-credentials.md new file mode 100644 index 0000000..fb44159 --- /dev/null +++ b/.frontloop/release-baseline/done/0050-pin-release-actions-installers-and-least-privilege-credentials.md @@ -0,0 +1,45 @@ +--- +title: Pin release actions installers and least-privilege credentials +priority: high +--- + +## Goal + +Remove mutable third-party execution and broad publication credentials from the release path. + +## Acceptance Criteria + +- GitHub Actions are pinned to reviewed commit SHAs +- Downloaded installers are pinned and checksum-verified or built from a locked dependency +- Job permissions are minimized and publication tokens are isolated to host jobs +- Homebrew writes cannot run on untrusted pull-request code +- Renovation instructions exist for intentional pin updates + +## Implementation Notes + +Apply after the release workflow dependency graph is explicit. + + +## Completion Summary + +- Pinned all external GitHub Actions to reviewed full commit SHAs with version/provenance comments +- Replaced mutable cargo-dist/rustup bootstrap behavior with checksum-verified local cargo-dist installer +- Set default contents:read permissions, isolated contents:write and GH_TOKEN to final host commands, and kept PR/reusable quality tokenless/read-only +- Removed Homebrew/tap/PAT release paths and enforced their absence +- Added fail-closed release security policy checker and regression tests covering actions, installer, Nix locks, workflow privileges, and banned patterns +- Added Dependabot configuration and documented manual pin review/update process +- Passed security-policy tests, full tests, fmt/clippy, docs, Nix package/smoke, 13 E2E scenarios, parallelism check, and Claude judgment + +### Files Changed + +- .github/workflows/release.yml +- .github/workflows/reusable-quality.yml +- .github/dependabot.yml +- scripts/install_cargo_dist.sh +- scripts/check_release_security.py +- scripts/tests/test_release_security_policy.py +- devenv.nix +- README.md +- documentation/docs/reference/release-security.md +- documentation/docs/reference/releasing.md +- documentation/docs/getting-started/install.md diff --git a/.frontloop/release-baseline/done/0060-add-dependency-policy-sbom-provenance-and-signing-gates.md b/.frontloop/release-baseline/done/0060-add-dependency-policy-sbom-provenance-and-signing-gates.md new file mode 100644 index 0000000..818b08e --- /dev/null +++ b/.frontloop/release-baseline/done/0060-add-dependency-policy-sbom-provenance-and-signing-gates.md @@ -0,0 +1,52 @@ +--- +title: Add dependency policy SBOM provenance and signing gates +priority: medium +--- + +## Goal + +Add advisory/license policy and verifiable supply-chain metadata for released crates and binaries. + +## Acceptance Criteria + +- Cargo and npm production dependencies have automated advisory and license checks with documented exceptions +- Each release produces an SBOM for shipped artifacts +- Provenance/attestations tie artifacts to the source SHA and workflow +- Checksums and signatures are published and verified by smoke tests +- Current npm audit findings are triaged rather than silently ignored + +## Implementation Notes + +Do after the trusted release workflow is established; avoid blocking integrity fixes on long-term signing choices. + + +## Completion Summary + +- Added fail-closed Cargo and production npm advisory/license policy with exact, owned, expiry-checked exceptions and explicit npm audit triage +- Generated artifact-derived CycloneDX SBOMs, checksums, provenance metadata, and keyless Sigstore signatures for shipped artifacts +- Bound verification to source SHA, release workflow, artifact bytes, and checksums; host fails closed on missing/tampered metadata +- Added exact tag-scoped release-workflow OIDC identity validation and restricted id-token permission to signing jobs +- Changed GitHub build provenance attestations to actual shipped artifact subjects, excluding metadata sidecars +- Added static policy/workflow/tamper regression tests and release-security documentation +- Regenerated canonical embedded Workbench assets; freshness, extracted crate verification, aggregate CI, and full task validation passed +- Accepted after independent judge remediation re-review + +### Files Changed + +- .github/workflows/release.yml +- .github/workflows/reusable-quality.yml +- supply-chain-policy.toml +- scripts/check_dependency_policy.py +- scripts/generate_release_metadata.py +- scripts/check_release_security.py +- scripts/package_artifact_smoke.py +- scripts/smoke_release_archive.py +- scripts/tests/test_supply_chain_gates.py +- scripts/tests/test_release_gate_workflows.py +- scripts/tests/test_release_security_policy.py +- devenv.nix +- documentation/docs/reference/release-security.md +- crates/brain-brew-cli/assets/workbench/index.html +- crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-24224569c4e4b16d_bg.wasm +- crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4.js +- crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4_bg.wasm diff --git a/.frontloop/release-baseline/done/choose-next-preview-version-and-supported-release-channels.md b/.frontloop/release-baseline/done/choose-next-preview-version-and-supported-release-channels.md new file mode 100644 index 0000000..635465c --- /dev/null +++ b/.frontloop/release-baseline/done/choose-next-preview-version-and-supported-release-channels.md @@ -0,0 +1,23 @@ +--- +title: Choose next preview version and supported release channels +priority: critical +--- + +## Goal + +Establish the immutable version and channel policy all publication work will implement; 1.0.0-alpha.1 is already published with incompatible interfaces. + +## Acceptance Criteria + +- Select the next workspace version and exact internal dependency version +- Choose supported channels for the next preview +- Define whether supported channels must publish together or may phase in +- Record the decision in release documentation + +## Implementation Notes + +Blocks version publication and the pinned Ultimate Geography baseline. + +## Questions + +### Q1: Recommended: use the next prerelease version, support crates.io plus pinned GitHub artifacts first, and advertise Homebrew/Nix only after their gates are green. Which version and channels should be authoritative? diff --git a/.frontloop/release-baseline/epic.md b/.frontloop/release-baseline/epic.md new file mode 100644 index 0000000..3123b86 --- /dev/null +++ b/.frontloop/release-baseline/epic.md @@ -0,0 +1,15 @@ +--- +title: Release baseline and trusted distribution +slug: release-baseline +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Restore a publishable, independently verified Brain Brew release baseline across crates.io, Nix, GitHub artifacts, and whichever installation channels are explicitly selected. Eliminate immutable-version reuse, path-only smoke tests, ungated publication, mutable workflow inputs, and unsupported install claims. + +## Sequence + +Resolve version/channel policy first, then repair extracted-package and Nix gates, wire those gates into release, harden supply-chain inputs, and only then advertise or extend release channels. diff --git a/.frontloop/translation-integrity/done/0010-centralize-translation-dictionary-mutation-invariants-in-core.md b/.frontloop/translation-integrity/done/0010-centralize-translation-dictionary-mutation-invariants-in-core.md new file mode 100644 index 0000000..6b9f64a --- /dev/null +++ b/.frontloop/translation-integrity/done/0010-centralize-translation-dictionary-mutation-invariants-in-core.md @@ -0,0 +1,47 @@ +--- +title: Centralize translation dictionary mutation invariants in core +priority: critical +--- + +## Goal + +Replace public-map surgery duplicated in CLI and Workbench with domain commands for direct/contextual/no-change/stale/adaptation changes. + +## Acceptance Criteria + +- Core exposes typed commands such as set_direct, set_contextual, set_no_change, record_source_change, and resolve_stale +- Commands enforce cross-map exclusivity and precedence +- CLI and Workbench use the same policy implementation +- Property tests cover command sequences and canonical results +- Public raw-map mutation is reduced or clearly internalized + +## Implementation Notes + +Can start while policy decisions resolve, but final command semantics must implement them. + + +## Completion Summary + +- Centralized dictionary mutation, validation, stale resolution, and coverage repair in core transactional APIs +- Removed legacy stale-resolution and CLI direct-map mutation bypasses +- Made field-definition identifiers structural while preserving display-variable translation +- Kept source and resolved OverlaySourceDocument views synchronized atomically +- Migrated fixture blank direct translations to explicit path-scoped adaptations +- Added atomicity, stale-resolution, view-sync, and command-sequence regression coverage +- Passed focused checks and aggregate CI; independent review required remediation then accepted the delta + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/src/translation_mutation.rs +- crates/brain-brew-core/tests/translation_dictionary_mutation.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-formats/src/overlay_source_document.rs +- crates/brain-brew-formats/src/source_document.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/tests/source_documents.rs +- crates/brain-brew-formats/tests/overlay_yaml.rs +- crates/brain-brew-cli/src/commands/translation_overlay.rs +- crates/brain-brew-cli/tests/cli.rs +- fixtures/ultimate-geography/overlays/languages/*.yaml diff --git a/.frontloop/translation-integrity/done/0020-implement-structural-field-name-safety-and-anki-reference-validation.md b/.frontloop/translation-integrity/done/0020-implement-structural-field-name-safety-and-anki-reference-validation.md new file mode 100644 index 0000000..b7dec63 --- /dev/null +++ b/.frontloop/translation-integrity/done/0020-implement-structural-field-name-safety-and-anki-reference-validation.md @@ -0,0 +1,43 @@ +--- +title: Implement structural field-name safety and Anki reference validation +priority: critical +--- + +## Goal + +Apply the chosen field-name policy and ensure no localized export can retain stale `{{Field}}` references. + +## Acceptance Criteria + +- Field names are excluded from translation or all references are atomically rewritten according to decision +- Template validation resolves Mustache field references against the final note-type schema +- Coverage no longer encourages unsafe identifier translation +- Existing dictionaries receive migration diagnostics +- The Capital→Hauptstadt reproduction fails safely or exports fully rewritten cards + +## Implementation Notes + +Depends on field-name decision and canonical source migration support. + + +## Completion Summary + +- Made Anki field identifiers structural and emitted typed migration diagnostics for forbidden field-name translations +- Added final-schema Mustache reference validation in core and revalidation after variable rendering at CrowdAnki export +- Covered direct/triple/section/filter reference grammar plus malformed and unknown references +- Prevented translation coverage/stub generation from re-soliciting structural identifiers +- Added Capital→Hauptstadt regression proving validation fails before output publication +- Passed focused core/formats/CLI tests, fmt, clippy, full CI, and independent Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/template_validation.rs +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/src/validate.rs +- crates/brain-brew-core/tests/template_field_validation.rs +- crates/brain-brew-core/tests/translation_coverage.rs +- crates/brain-brew-formats/tests/crowdanki.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/tests/cli_contract.rs diff --git a/.frontloop/translation-integrity/done/0030-implement-compositional-translation-coverage-and-ownership.md b/.frontloop/translation-integrity/done/0030-implement-compositional-translation-coverage-and-ownership.md new file mode 100644 index 0000000..e1e987a --- /dev/null +++ b/.frontloop/translation-integrity/done/0030-implement-compositional-translation-coverage-and-ownership.md @@ -0,0 +1,39 @@ +--- +title: Implement compositional translation coverage and ownership +priority: critical +--- + +## Goal + +Certify final target stacks across split dictionaries while attributing missing/stale content to the overlay responsible for it. + +## Acceptance Criteria + +- Strict coverage follows the approved ownership model across the ordered target stack +- Base and extension dictionaries can jointly satisfy completeness without hidden fallback +- Reports identify responsible source overlay, path, source text, and status +- Ignored/no-change/stale/adaptation categories remain explicit +- Main/Hardcore fixture tests cover complete and incomplete stacks + +## Implementation Notes + +Depends on strict-policy decision and centralized mutation commands. + + +## Completion Summary + +- Added pure-core deterministic ordered-stack coverage with final-target completeness and introducing-overlay ownership +- Preserved stale review debt across shadowing and attributed strict diagnostics to responsible overlays +- Made deletion, adaptation, ignored, and fallback states explicit without adapter/structural inflation +- Migrated strict CLI verification to the shared coverage report +- Added Main/Hardcore, real UG manifest, deterministic ordering, and CLI ownership diagnostics tests +- Passed full CI and independent Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-cli/src/commands/verify.rs +- crates/brain-brew-cli/tests/translations_cli.rs +- crates/brain-brew-formats/tests/ultimate_geography_fixture.rs diff --git a/.frontloop/translation-integrity/done/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md b/.frontloop/translation-integrity/done/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md new file mode 100644 index 0000000..2fbfe85 --- /dev/null +++ b/.frontloop/translation-integrity/done/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md @@ -0,0 +1,41 @@ +--- +title: Make blank deletion and target adaptations explicit and general +priority: high +--- + +## Goal + +Implement the approved blank policy and replace UG-specific magic-reason `target_additions` with a documented typed adaptation model. + +## Acceptance Criteria + +- Blank direct entries cannot silently erase global content +- Explicit deletions/adaptations are path-scoped, reviewed, and represented in coverage +- The magic UG reason string is removed from format semantics +- Schema and migration docs explain the general adaptation model +- Round-trip tests cover legacy UG data and canonical new emission + +## Implementation Notes + +Depends on blank-policy decision and strict YAML union work. + + +## Completion Summary + +- Replaced UG magic target-addition semantics with typed path-scoped adaptation and deletion decisions +- Rejected blank direct/contextual/stale translations; only explicit scoped deletion can remove content +- Added expected-source, intent, ownership, required reason, validation, coverage states, and transactional mutation behavior +- Added legacy YAML compatibility reader, canonical typed writer, warnings, migration diagnostics, and round-trip tests +- Documented general adaptation/deletion schema and migration behavior +- Independently judged ACCEPT + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/src/translation_mutation.rs +- crates/brain-brew-formats/src/canonical_yaml.rs +- crates/brain-brew-formats/src/fmt.rs +- crates/brain-brew-cli/src/cli.rs +- documentation/docs/authoring/translations.md +- documentation/docs/reference/yaml.md diff --git a/.frontloop/translation-integrity/done/0050-unify-translation-reporting-resolution-and-json-behavior.md b/.frontloop/translation-integrity/done/0050-unify-translation-reporting-resolution-and-json-behavior.md new file mode 100644 index 0000000..3037a27 --- /dev/null +++ b/.frontloop/translation-integrity/done/0050-unify-translation-reporting-resolution-and-json-behavior.md @@ -0,0 +1,45 @@ +--- +title: Unify translation reporting resolution and JSON behavior +priority: medium +--- + +## Goal + +Make CLI and Workbench expose the same precedence, stale resolution, hidden/structural classification, coverage totals, and machine-readable failures. + +## Acceptance Criteria + +- One resolver/report model powers CLI and Workbench +- `translations --json` always returns valid structured success/error output +- Structural and hidden units are categorized rather than inflating fallback counts ambiguously +- Path/context lookup remains deterministic and has scale tests +- Documentation examples are generated from the report schema + +## Implementation Notes + +Final translation platform task after policy implementation. + + +## Blocked + +Agentleman worker launch is infrastructure-blocked: `launchPresets.worker-write-jack uses retired {agentlemanArgs} launch composition; v2 argv must be a complete opaque command.` Both direct `agm run` and visible delegation fail before creating a run. Translation 0050 implementation has not started; repair the Agentleman worker-write launch preset, then resume this final translation task. + + +## Completion Summary + +- Unified final-stack coverage resolver for CLI verification and Workbench views +- Added deterministic mutually exclusive final-stack status totals, including hidden and structural exclusions +- Added versioned JSON success envelopes and structured JSON-only error output for translations commands +- Added large-path deterministic report tests and CLI schema/error tests +- Schema-locked documentation examples to the supported JSON envelope +- Passed full CI and independent Claude judgment + +### Files Changed + +- crates/brain-brew-core/src/model.rs +- crates/brain-brew-core/src/translation.rs +- crates/brain-brew-core/tests/overlay_compose.rs +- crates/brain-brew-cli/src/commands/translations.rs +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/tests/translations_cli.rs +- documentation/docs/authoring/translations.md diff --git a/.frontloop/translation-integrity/done/choose-compositional-strict-translation-ownership-policy.md b/.frontloop/translation-integrity/done/choose-compositional-strict-translation-ownership-policy.md new file mode 100644 index 0000000..d40a270 --- /dev/null +++ b/.frontloop/translation-integrity/done/choose-compositional-strict-translation-ownership-policy.md @@ -0,0 +1,23 @@ +--- +title: Choose compositional strict translation ownership policy +priority: critical +--- + +## Goal + +Define how split base, extension, fill, and companion dictionaries jointly satisfy release completeness without judging every overlay against the entire intermediate deck. + +## Acceptance Criteria + +- Select combined target-stack completeness, source-unit ownership by introducing overlay, or another explicit model +- Define handling for shared content, structural units, ignored/no-change, and target adaptations +- Specify diagnostics attributable to the responsible overlay +- Provide examples for main and companion Hardcore stacks + +## Implementation Notes + +Blocks strict coverage implementation and UG translation certification. + +## Questions + +### Q1: Recommended: assign source-unit ownership to the overlay introducing the unit, then evaluate completeness jointly across the final target stack. Approve or choose combined-stack-only policy? diff --git a/.frontloop/translation-integrity/done/choose-explicit-blank-translation-semantics.md b/.frontloop/translation-integrity/done/choose-explicit-blank-translation-semantics.md new file mode 100644 index 0000000..c1db6d9 --- /dev/null +++ b/.frontloop/translation-integrity/done/choose-explicit-blank-translation-semantics.md @@ -0,0 +1,23 @@ +--- +title: Choose explicit blank translation semantics +priority: high +--- + +## Goal + +Replace implicit global deletion from blank direct translations with an intentional reviewed category or rejection policy. + +## Acceptance Criteria + +- Choose reject, explicit deletion/adaptation intent, or warning category +- Define path scoping and coverage effects +- Specify migration for existing blank entries +- Document Workbench and CLI authoring behavior + +## Implementation Notes + +Blocks blank-value behavior implementation. + +## Questions + +### Q1: Recommended: reject blank direct translations and require a path-scoped explicit deletion/target-adaptation intent. Approve or choose a reviewed blank category? diff --git a/.frontloop/translation-integrity/done/choose-whether-anki-field-names-are-structural.md b/.frontloop/translation-integrity/done/choose-whether-anki-field-names-are-structural.md new file mode 100644 index 0000000..06acf0b --- /dev/null +++ b/.frontloop/translation-integrity/done/choose-whether-anki-field-names-are-structural.md @@ -0,0 +1,23 @@ +--- +title: Choose whether Anki field names are structural +priority: critical +--- + +## Goal + +Decide whether field-definition names are non-translatable identifiers or may translate only with atomic Mustache/reference rewrite and validation. + +## Acceptance Criteria + +- Choose structural or fully rewritable semantics +- Define migration for existing field-name dictionary entries +- Specify validation for templates and other Anki references +- Document UI/coverage behavior for field labels versus identifiers + +## Implementation Notes + +Blocks field-name safety implementation. + +## Questions + +### Q1: Recommended: make Anki field names structural and non-translatable; introduce separate display-label content if needed. Approve or require atomic reference rewriting? diff --git a/.frontloop/translation-integrity/epic.md b/.frontloop/translation-integrity/epic.md new file mode 100644 index 0000000..53a55e4 --- /dev/null +++ b/.frontloop/translation-integrity/epic.md @@ -0,0 +1,15 @@ +--- +title: Translation integrity and compositional coverage +slug: translation-integrity +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Define and enforce safe structural versus translatable content, compositional ownership across overlay stacks, explicit deletion semantics, and one centralized translation mutation policy shared by CLI and Workbench. + +## Sequence + +Resolve field-name, blank-value, and strict-coverage decisions first; centralize policy; implement the selected semantics; replace UG-specific hidden schema and make reporting machine reliable. diff --git a/.frontloop/translation-integrity/in_progress/0020-implement-structural-field-name-safety-and-anki-reference-validation.md b/.frontloop/translation-integrity/in_progress/0020-implement-structural-field-name-safety-and-anki-reference-validation.md new file mode 100644 index 0000000..f79720a --- /dev/null +++ b/.frontloop/translation-integrity/in_progress/0020-implement-structural-field-name-safety-and-anki-reference-validation.md @@ -0,0 +1,20 @@ +--- +title: Implement structural field-name safety and Anki reference validation +priority: critical +--- + +## Goal + +Apply the chosen field-name policy and ensure no localized export can retain stale `{{Field}}` references. + +## Acceptance Criteria + +- Field names are excluded from translation or all references are atomically rewritten according to decision +- Template validation resolves Mustache field references against the final note-type schema +- Coverage no longer encourages unsafe identifier translation +- Existing dictionaries receive migration diagnostics +- The Capital→Hauptstadt reproduction fails safely or exports fully rewritten cards + +## Implementation Notes + +Depends on field-name decision and canonical source migration support. diff --git a/.frontloop/translation-integrity/in_progress/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md b/.frontloop/translation-integrity/in_progress/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md new file mode 100644 index 0000000..bfe1365 --- /dev/null +++ b/.frontloop/translation-integrity/in_progress/0040-make-blank-deletion-and-target-adaptations-explicit-and-general.md @@ -0,0 +1,20 @@ +--- +title: Make blank deletion and target adaptations explicit and general +priority: high +--- + +## Goal + +Implement the approved blank policy and replace UG-specific magic-reason `target_additions` with a documented typed adaptation model. + +## Acceptance Criteria + +- Blank direct entries cannot silently erase global content +- Explicit deletions/adaptations are path-scoped, reviewed, and represented in coverage +- The magic UG reason string is removed from format semantics +- Schema and migration docs explain the general adaptation model +- Round-trip tests cover legacy UG data and canonical new emission + +## Implementation Notes + +Depends on blank-policy decision and strict YAML union work. diff --git a/.frontloop/translation-integrity/ready/0030-implement-compositional-translation-coverage-and-ownership.md b/.frontloop/translation-integrity/ready/0030-implement-compositional-translation-coverage-and-ownership.md new file mode 100644 index 0000000..08e73c8 --- /dev/null +++ b/.frontloop/translation-integrity/ready/0030-implement-compositional-translation-coverage-and-ownership.md @@ -0,0 +1,20 @@ +--- +title: Implement compositional translation coverage and ownership +priority: critical +--- + +## Goal + +Certify final target stacks across split dictionaries while attributing missing/stale content to the overlay responsible for it. + +## Acceptance Criteria + +- Strict coverage follows the approved ownership model across the ordered target stack +- Base and extension dictionaries can jointly satisfy completeness without hidden fallback +- Reports identify responsible source overlay, path, source text, and status +- Ignored/no-change/stale/adaptation categories remain explicit +- Main/Hardcore fixture tests cover complete and incomplete stacks + +## Implementation Notes + +Depends on strict-policy decision and centralized mutation commands. diff --git a/.frontloop/translation-integrity/ready/0050-unify-translation-reporting-resolution-and-json-behavior.md b/.frontloop/translation-integrity/ready/0050-unify-translation-reporting-resolution-and-json-behavior.md new file mode 100644 index 0000000..d7cef3d --- /dev/null +++ b/.frontloop/translation-integrity/ready/0050-unify-translation-reporting-resolution-and-json-behavior.md @@ -0,0 +1,20 @@ +--- +title: Unify translation reporting resolution and JSON behavior +priority: medium +--- + +## Goal + +Make CLI and Workbench expose the same precedence, stale resolution, hidden/structural classification, coverage totals, and machine-readable failures. + +## Acceptance Criteria + +- One resolver/report model powers CLI and Workbench +- `translations --json` always returns valid structured success/error output +- Structural and hidden units are categorized rather than inflating fallback counts ambiguously +- Path/context lookup remains deterministic and has scale tests +- Documentation examples are generated from the report schema + +## Implementation Notes + +Final translation platform task after policy implementation. diff --git a/.frontloop/ultimate-geography-production/done/choose-disposition-of-legacy-ug-maintenance-workflows.md b/.frontloop/ultimate-geography-production/done/choose-disposition-of-legacy-ug-maintenance-workflows.md new file mode 100644 index 0000000..bdd6deb --- /dev/null +++ b/.frontloop/ultimate-geography-production/done/choose-disposition-of-legacy-ug-maintenance-workflows.md @@ -0,0 +1,23 @@ +--- +title: Choose disposition of legacy UG maintenance workflows +priority: medium +--- + +## Goal + +Decide separately whether Anki-to-source reconciliation, deleted-flag similarity analysis, and ZIP release packaging are restored or formally deprecated with replacements. + +## Acceptance Criteria + +- Record restore/deprecate decision for all three workflows +- Name the replacement or implementation owner for each +- Update the definition of fully migrated UG accordingly +- Remove stale workflow promises after the decision + +## Implementation Notes + +Can resolve while core production migration proceeds. + +## Questions + +### Q1: Recommended: restore deterministic declared-media ZIP packaging, defer Anki reconciliation to default/7500, and formally deprecate flag similarity unless an active maintainer need is demonstrated. Approve or revise. diff --git a/.frontloop/ultimate-geography-production/done/choose-the-ug-fixture-contract.md b/.frontloop/ultimate-geography-production/done/choose-the-ug-fixture-contract.md new file mode 100644 index 0000000..610c03f --- /dev/null +++ b/.frontloop/ultimate-geography-production/done/choose-the-ug-fixture-contract.md @@ -0,0 +1,23 @@ +--- +title: Choose the UG fixture contract +priority: critical +--- + +## Goal + +Decide whether Brain Brew's UG fixture is an exact production snapshot, production plus an explicit generated migration patch, or an intentionally future-facing fixture paired with a mandatory live-consumer gate. + +## Acceptance Criteria + +- Select one fixture contract and owner +- Define how sync detects drift and what generated changes require review +- Specify whether fixture-only language/profile/media migrations are permitted +- Define the mandatory production-consumer CI signal + +## Implementation Notes + +Blocks fixture synchronization redesign but not production canonicalization. + +## Questions + +### Q1: Recommended: track an exact pinned production snapshot plus an explicit reviewed migration transform, and require both fixture and live-consumer gates. Approve or choose another contract? diff --git a/.frontloop/ultimate-geography-production/epic.md b/.frontloop/ultimate-geography-production/epic.md new file mode 100644 index 0000000..ec6f30d --- /dev/null +++ b/.frontloop/ultimate-geography-production/epic.md @@ -0,0 +1,15 @@ +--- +title: Ultimate Geography production migration +slug: ultimate-geography-production +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Make the real Ultimate Geography checkout—not only Brain Brew's migrated fixture—the mandatory first-consumer acceptance baseline. Produce canonical, hashed, correctly translated, independently compared, reproducibly packaged releases under a pinned Brain Brew tool. + +## Sequence + +Pin/reconcile the baseline and decide fixture policy, canonicalize source, migrate media, repair translation/catalog behavior, establish independent goldens, then automate CI and packaging and resolve legacy workflow decisions. diff --git a/.frontloop/ultimate-geography-production/ready/0010-establish-a-pinned-reconciled-ug-consumer-baseline.md b/.frontloop/ultimate-geography-production/ready/0010-establish-a-pinned-reconciled-ug-consumer-baseline.md new file mode 100644 index 0000000..69ceb28 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0010-establish-a-pinned-reconciled-ug-consumer-baseline.md @@ -0,0 +1,20 @@ +--- +title: Establish a pinned reconciled UG consumer baseline +priority: critical +--- + +## Goal + +Reconcile migration/upstream history and pin the exact Brain Brew source revision, UG revision, parity baseline, and command set used for acceptance. + +## Acceptance Criteria + +- Migration and upstream divergence is reconciled or explicitly documented with a stable base +- UG CI and contributor instructions use an immutable Brain Brew revision/version +- Parity inputs are repository-relative and immutable +- A baseline report records current target counts and known migration deltas +- The pinned tool builds through its documented installation path + +## Implementation Notes + +First production task; depends on release version/channel choice for final pin. diff --git a/.frontloop/ultimate-geography-production/ready/0020-canonicalize-the-real-ug-source-under-the-pinned-tool.md b/.frontloop/ultimate-geography-production/ready/0020-canonicalize-the-real-ug-source-under-the-pinned-tool.md new file mode 100644 index 0000000..935a392 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0020-canonicalize-the-real-ug-source-under-the-pinned-tool.md @@ -0,0 +1,20 @@ +--- +title: Canonicalize the real UG source under the pinned tool +priority: critical +--- + +## Goal + +Apply and review canonical formatting to production deck, Hardcore, language, extension, and variant sources—not only the fixture. + +## Acceptance Criteria + +- `brainbrew fmt --check` passes over every production source +- The current 20-file formatter delta is reviewed for semantic neutrality +- No include or structured content is lost +- All 74 main and 26 Hardcore targets compose after canonicalization +- Canonical source changes are committed independently from behavioral migration + +## Implementation Notes + +After pinned baseline; use strict duplicate/union decoder before accepting mass formatting. diff --git a/.frontloop/ultimate-geography-production/ready/0030-migrate-declare-and-hash-all-production-media.md b/.frontloop/ultimate-geography-production/ready/0030-migrate-declare-and-hash-all-production-media.md new file mode 100644 index 0000000..b219e7b --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0030-migrate-declare-and-hash-all-production-media.md @@ -0,0 +1,20 @@ +--- +title: Migrate declare and hash all production media +priority: critical +--- + +## Goal + +Replace empty/inline media declarations with complete structured references and verified hashes rooted in production media bytes. + +## Acceptance Criteria + +- All 546 base and five Experimental empty hashes are populated from verified bytes +- Raw/inline references are migrated or explicitly allowlisted +- Every referenced asset is declared and every declared asset is present according to policy +- Main and Hardcore verify/export succeed with media validation +- Generated outputs copy only declared assets and no blanket `cp media/*` masks gaps + +## Implementation Notes + +After canonical source and safe media mutation tooling. diff --git a/.frontloop/ultimate-geography-production/ready/0040-add-production-language-catalog-and-translation-profiles.md b/.frontloop/ultimate-geography-production/ready/0040-add-production-language-catalog-and-translation-profiles.md new file mode 100644 index 0000000..28e74dd --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0040-add-production-language-catalog-and-translation-profiles.md @@ -0,0 +1,20 @@ +--- +title: Add production language catalog and translation profiles +priority: high +--- + +## Goal + +Make the documented Workbench and translation commands operate against real UG manifests instead of fixture-only catalog/profile state. + +## Acceptance Criteria + +- Production manifests declare all supported languages and profiles canonically +- Workbench accepts representative language selections including German +- Catalog/profile data has one source of truth across main and companion manifests +- Consistency tests detect duplicated companion drift +- Fixture generation follows the approved fixture contract + +## Implementation Notes + +After fixture policy; coordinate with translation ownership model. diff --git a/.frontloop/ultimate-geography-production/ready/0050-fix-localized-hardcore-overlay-ordering-and-companion-content.md b/.frontloop/ultimate-geography-production/ready/0050-fix-localized-hardcore-overlay-ordering-and-companion-content.md new file mode 100644 index 0000000..9f5f1e1 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0050-fix-localized-hardcore-overlay-ordering-and-companion-content.md @@ -0,0 +1,20 @@ +--- +title: Fix localized Hardcore overlay ordering and companion content +priority: critical +--- + +## Goal + +Ensure extension/fill content is translated by the correct companion dictionaries before localized Hardcore targets are certified. + +## Acceptance Criteria + +- Main localized Hardcore targets select required companion content translations +- Overlay order is explicit and matches the standalone companion semantics +- Fresh cs/de/non-Latin/RTL exports contain no unintended English extension prose +- Strict compositional coverage passes under the chosen policy +- Manifest tests prevent future order regressions + +## Implementation Notes + +Depends on compositional translation ownership implementation. diff --git a/.frontloop/ultimate-geography-production/ready/0060-unify-and-translate-the-duplicated-capital-hint-variable.md b/.frontloop/ultimate-geography-production/ready/0060-unify-and-translate-the-duplicated-capital-hint-variable.md new file mode 100644 index 0000000..1e94466 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0060-unify-and-translate-the-duplicated-capital-hint-variable.md @@ -0,0 +1,20 @@ +--- +title: Unify and translate the duplicated capital hint variable +priority: high +--- + +## Goal + +Remove the two-source-variable mismatch that leaves mixed-language card faces in 13 languages. + +## Acceptance Criteria + +- Capital hint templates reference one intentional source unit or both units are linked by a documented invariant +- All affected language dictionaries are migrated +- Every 13-language reproduction renders translated hint text consistently +- Extraction/coverage reports contain no accidental duplicate source unit +- Regression tests exercise question and answer templates + +## Implementation Notes + +Can proceed after source canonicalization; follow variable-first Federated Deck skill. diff --git a/.frontloop/ultimate-geography-production/ready/0070-commit-mandatory-representative-crowdanki-goldens.md b/.frontloop/ultimate-geography-production/ready/0070-commit-mandatory-representative-crowdanki-goldens.md new file mode 100644 index 0000000..b75f5c5 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0070-commit-mandatory-representative-crowdanki-goldens.md @@ -0,0 +1,20 @@ +--- +title: Commit mandatory representative CrowdAnki goldens +priority: critical +--- + +## Goal + +Add independent non-optional golden exports covering representative production target families and languages. + +## Acceptance Criteria + +- Goldens cover source language, non-Latin, RTL/CJK, Extended, Experimental, main Hardcore, and companion Hardcore +- Manifest targets configure the golden paths explicitly +- Missing goldens fail rather than early-return success +- Comparison uses the complete normalized equivalence oracle +- Intentional deviations use narrow reviewed allowlists with rationale + +## Implementation Notes + +Depends on canonical/media/translation fixes and complete CrowdAnki oracle. diff --git a/.frontloop/ultimate-geography-production/ready/0080-deepen-historical-parity-across-all-target-families.md b/.frontloop/ultimate-geography-production/ready/0080-deepen-historical-parity-across-all-target-families.md new file mode 100644 index 0000000..cd1225c --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0080-deepen-historical-parity-across-all-target-families.md @@ -0,0 +1,20 @@ +--- +title: Deepen historical parity across all target families +priority: high +--- + +## Goal + +Replace the 12-target name/count signature with immutable comparisons of templates, CSS, field schema/order, config, notes, tags, identity, and media content. + +## Acceptance Criteria + +- Parity sampling covers all materially distinct target families or all 100 targets where feasible +- Baseline revisions and artifacts are immutable +- Model/template/config/media signatures observe every release-relevant property +- Differences are classified and reviewed, not silently normalized +- The evidence script fails on missing baseline data + +## Implementation Notes + +Build on committed goldens; keep historical and current-tool oracles independent. diff --git a/.frontloop/ultimate-geography-production/ready/0090-automate-reproducible-declared-media-release-packaging.md b/.frontloop/ultimate-geography-production/ready/0090-automate-reproducible-declared-media-release-packaging.md new file mode 100644 index 0000000..7cec130 --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0090-automate-reproducible-declared-media-release-packaging.md @@ -0,0 +1,20 @@ +--- +title: Automate reproducible declared-media release packaging +priority: high +--- + +## Goal + +Produce correctly named and validated UG release archives from composed targets without recopying the entire media tree. + +## Acceptance Criteria + +- Archive names/layout derive from one version/source-of-truth model +- Only declared verified media is included +- Every archive contains a valid CrowdAnki deck and passes import/smoke validation +- Main and companion outputs cannot overwrite or contaminate one another +- Release descriptions read version text from one canonical source + +## Implementation Notes + +Depends on media completion and legacy workflow disposition. diff --git a/.frontloop/ultimate-geography-production/ready/0100-make-real-ug-a-required-brain-brew-release-gate.md b/.frontloop/ultimate-geography-production/ready/0100-make-real-ug-a-required-brain-brew-release-gate.md new file mode 100644 index 0000000..382529d --- /dev/null +++ b/.frontloop/ultimate-geography-production/ready/0100-make-real-ug-a-required-brain-brew-release-gate.md @@ -0,0 +1,20 @@ +--- +title: Make real UG a required Brain Brew release gate +priority: critical +--- + +## Goal + +Run pinned production UG format, all-target verify with media, representative export, Workbench smoke, and independent goldens before Brain Brew or UG publication. + +## Acceptance Criteria + +- The gate checks the reconciled immutable UG revision +- All 74 main and 26 Hardcore targets verify with media bytes +- Representative outputs pass mandatory goldens and archive validation +- Workbench language selection uses production catalog data +- Brain Brew release workflow and UG CI both fail on consumer regression + +## Implementation Notes + +Final UG acceptance task after all production blockers; integrate into release-baseline reusable workflow. diff --git a/.frontloop/workbench-hardening/done/0010-mark-unsafe-apply-experimental-or-make-release-workbench-read-only.md b/.frontloop/workbench-hardening/done/0010-mark-unsafe-apply-experimental-or-make-release-workbench-read-only.md new file mode 100644 index 0000000..c601e15 --- /dev/null +++ b/.frontloop/workbench-hardening/done/0010-mark-unsafe-apply-experimental-or-make-release-workbench-read-only.md @@ -0,0 +1,50 @@ +--- +title: Mark unsafe Apply experimental or make release Workbench read-only +priority: critical +--- + +## Goal + +Prevent stale/partial/canonicality-unsafe writes from being presented as a supported release workflow until the hardening sequence lands. + +## Acceptance Criteria + +- Release behavior is explicitly read-only or guarded by an unmistakable experimental opt-in +- Documentation and UI explain the current risk and retained staging/export options +- API tests prove writes are unavailable without the opt-in +- No existing verify/browse workflow regresses +- A removal condition references the CAS, canonical mutation, preview, and recovery tasks + +## Implementation Notes + +First Workbench task; intentionally a temporary containment measure. + + +## Completion Summary + +- Made default and distributed Workbench builds read-only at the server layer +- Required both compile-time workbench-write-dev capability and explicit --enable-write for development writes, with no environment bypass +- Rejected Apply and new-language writes with HTTP 403 before workspace/cache/file side effects in default mode +- Exposed server write capability to the UI, added prominent read-only/development warnings, retained local drafts and browse/compare/media/preview +- Added direct-HTTP containment, feature/flag, UI host, and dual-mode browser coverage +- Documented known risks and exact CAS/preview/canonical-transaction/security removal conditions +- Rebuilt embedded assets and passed default/development tests, clippy, UI build/embed, 13 E2E, docs, release containment proof, and Claude judgment + +### Files Changed + +- crates/brain-brew-cli/Cargo.toml +- crates/brain-brew-cli/src/commands/workbench.rs +- crates/brain-brew-cli/src/help.rs +- crates/brain-brew-cli/tests/cli.rs +- crates/brain-brew-workbench-ui/src/lib.rs +- crates/brain-brew-workbench-ui/static/workbench.css +- crates/brain-brew-workbench-ui/tests/workspace_summary.rs +- crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs +- crates/brain-brew-workbench-e2e/README.md +- crates/brain-brew-cli/assets/workbench/index.html +- crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-202dc73fcf491da9.js +- crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-202dc73fcf491da9_bg.wasm +- crates/brain-brew-cli/assets/workbench/workbench-66e33cced77ddc3f.css +- devenv.nix +- documentation/docs/reference/cli.md +- documentation/docs/reference/workbench.md diff --git a/.frontloop/workbench-hardening/done/choose-workbench-trust-and-transaction-scope.md b/.frontloop/workbench-hardening/done/choose-workbench-trust-and-transaction-scope.md new file mode 100644 index 0000000..8bfa83a --- /dev/null +++ b/.frontloop/workbench-hardening/done/choose-workbench-trust-and-transaction-scope.md @@ -0,0 +1,23 @@ +--- +title: Choose Workbench trust and transaction scope +priority: critical +--- + +## Goal + +Define the supported browser/workspace threat model and whether cross-root or cross-filesystem files may participate in one Apply. + +## Acceptance Criteria + +- State whether untrusted workspaces and foreign browser origins are in scope +- Choose capability-token, Host/Origin, CSP, and rendered-content policy +- Define whether external include roots or cross-filesystem batches are rejected or recoverably supported +- Document the maturity of the Workbench interface as stable or experimental + +## Implementation Notes + +Immediate read-only restriction can proceed before this decision. + +## Questions + +### Q1: Recommended: treat workspaces/content as untrusted, require a per-process capability plus Host/Origin checks and CSP, and reject Apply batches that cannot use the recoverable transaction contract. Approve this scope? diff --git a/.frontloop/workbench-hardening/epic.md b/.frontloop/workbench-hardening/epic.md new file mode 100644 index 0000000..4bcd9b3 --- /dev/null +++ b/.frontloop/workbench-hardening/epic.md @@ -0,0 +1,15 @@ +--- +title: Workbench safety and completeness +slug: workbench-hardening +status: active +created_at: 2026-07-09 +completed_at: +--- + +## Goal + +Turn the Leptos Workbench from a broad happy-path editor into a conflict-safe, recoverable, bounded, secure local maintainer tool with complete pagination/detail workflows and a typed server/UI contract. + +## Sequence + +Constrain unsafe Apply immediately, decide the threat model, establish typed contracts, add compare-and-swap/preview/transaction safety, then complete editing workflows, security, cache correctness, and remove compatibility pivots. diff --git a/.frontloop/workbench-hardening/ready/0020-introduce-a-typed-versioned-workbench-server-ui-contract.md b/.frontloop/workbench-hardening/ready/0020-introduce-a-typed-versioned-workbench-server-ui-contract.md new file mode 100644 index 0000000..1b3622a --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0020-introduce-a-typed-versioned-workbench-server-ui-contract.md @@ -0,0 +1,20 @@ +--- +title: Introduce a typed versioned Workbench server UI contract +priority: critical +--- + +## Goal + +Replace ad hoc JSON values and silent client defaults with shared DTOs, enums, version negotiation, and one error envelope. + +## Acceptance Criteria + +- Server and independently compiled UI share or generate request/response contract types +- All routes include an explicit contract version +- Unknown/missing fields fail visibly rather than defaulting silently +- API 404 and failures use one typed error envelope +- Request/body/list limits are part of the interface and contract-tested + +## Implementation Notes + +Coordinate typed core errors; establish before changing Apply or detail routes. diff --git a/.frontloop/workbench-hardening/ready/0030-enforce-capability-host-origin-csp-and-rendered-content-policy.md b/.frontloop/workbench-hardening/ready/0030-enforce-capability-host-origin-csp-and-rendered-content-policy.md new file mode 100644 index 0000000..2cb40df --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0030-enforce-capability-host-origin-csp-and-rendered-content-policy.md @@ -0,0 +1,20 @@ +--- +title: Enforce capability Host Origin CSP and rendered-content policy +priority: critical +--- + +## Goal + +Harden the loopback file-writing server and browser document according to the approved trust model. + +## Acceptance Criteria + +- Each process requires an unguessable capability for state-changing and sensitive routes +- Unexpected Host and Origin requests are rejected +- CSP prevents unexpected script/network execution +- Rendered deck HTML is sandboxed or sanitized according to policy rather than entering unrestricted innerHTML +- Request limits and CSRF-like cross-origin tests cover foreign sites + +## Implementation Notes + +Depends on trust-model decision and typed routing contract. diff --git a/.frontloop/workbench-hardening/ready/0040-add-compare-and-swap-fingerprints-to-every-apply-input.md b/.frontloop/workbench-hardening/ready/0040-add-compare-and-swap-fingerprints-to-every-apply-input.md new file mode 100644 index 0000000..f7cebde --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0040-add-compare-and-swap-fingerprints-to-every-apply-input.md @@ -0,0 +1,20 @@ +--- +title: Add compare-and-swap fingerprints to every Apply input +priority: critical +--- + +## Goal + +Reject drafts when any affected canonical, overlay, include, or locked dependency input changed since preview/context load. + +## Acceptance Criteria + +- Apply carries expected fingerprints/generations for the complete transitive source set +- Server recomputes and compares all preconditions immediately before commit +- Stale external edits return a typed conflict without modifying files +- Fingerprints include scalar/media includes and package dependencies +- Concurrency tests cover same-file and related-file changes + +## Implementation Notes + +Depends on typed contracts and provenance-aware source planning. diff --git a/.frontloop/workbench-hardening/ready/0050-bind-confirm-to-an-immutable-validated-preview.md b/.frontloop/workbench-hardening/ready/0050-bind-confirm-to-an-immutable-validated-preview.md new file mode 100644 index 0000000..447304a --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0050-bind-confirm-to-an-immutable-validated-preview.md @@ -0,0 +1,20 @@ +--- +title: Bind Confirm to an immutable validated preview +priority: critical +--- + +## Goal + +Require a server-issued preview token that commits exactly the reviewed edit set against the reviewed fingerprints. + +## Acceptance Criteria + +- Preview validates canonical result, domain composition, policy, and complete file plan +- Server returns a short-lived single-use token bound to edits and fingerprints +- Confirm cannot alter or bypass the previewed payload +- Expired, reused, stale, or mismatched tokens fail without writes +- Browser E2E covers preview-conflict-confirm and cancellation + +## Implementation Notes + +After typed contracts and CAS; token is not a substitute for commit-time CAS. diff --git a/.frontloop/workbench-hardening/ready/0060-commit-workbench-changes-through-canonical-documents-and-recovery-transactions.md b/.frontloop/workbench-hardening/ready/0060-commit-workbench-changes-through-canonical-documents-and-recovery-transactions.md new file mode 100644 index 0000000..1c0f4ee --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0060-commit-workbench-changes-through-canonical-documents-and-recovery-transactions.md @@ -0,0 +1,20 @@ +--- +title: Commit Workbench changes through canonical documents and recovery transactions +priority: critical +--- + +## Goal + +Make every Apply include-preserving, canonical, all-or-recoverable, and constrained to authorized files. + +## Acceptance Criteria + +- No Workbench route serializes generic serde YAML for source edits +- All edits use canonical source-document operations +- All files commit through the journaled workspace transaction module +- Injected rename/crash failures roll back or recover on restart +- The prior include-bearing deck and partial-rename reproductions pass as regressions + +## Implementation Notes + +Depends on canonical-source-integrity modules, CAS, and preview binding. diff --git a/.frontloop/workbench-hardening/ready/0070-complete-pagination-and-item-scoped-detail-interfaces.md b/.frontloop/workbench-hardening/ready/0070-complete-pagination-and-item-scoped-detail-interfaces.md new file mode 100644 index 0000000..b31da38 --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0070-complete-pagination-and-item-scoped-detail-interfaces.md @@ -0,0 +1,20 @@ +--- +title: Complete pagination and item-scoped detail interfaces +priority: high +--- + +## Goal + +Make every item beyond row 50 reachable and complete ADR-015 list/detail behavior for notes, cards, source strings, metadata, and comparisons. + +## Acceptance Criteria + +- UI exposes deterministic paging or virtualization for all lists +- Card, source-string, and metadata detail routes return selected-item payloads +- Comparison is selected-item scoped rather than whole-deck +- List endpoints avoid constructing full detail payloads before slicing +- E2E reaches and edits item 51+ in every applicable view + +## Implementation Notes + +Can begin after typed contracts; retain default/0150 cross-language matrix as a related independent task. diff --git a/.frontloop/workbench-hardening/ready/0080-build-a-workspace-wide-draft-manager-and-recovery-ux.md b/.frontloop/workbench-hardening/ready/0080-build-a-workspace-wide-draft-manager-and-recovery-ux.md new file mode 100644 index 0000000..94f5bfa --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0080-build-a-workspace-wide-draft-manager-and-recovery-ux.md @@ -0,0 +1,20 @@ +--- +title: Build a workspace-wide draft manager and recovery UX +priority: high +--- + +## Goal + +Ensure all staged scopes remain visible/applicable across unmounted languages and targets, with explicit discard, conflict, and restart recovery. + +## Acceptance Criteria + +- Draft inventory is independent of currently mounted DOM/view prefixes +- Apply includes exactly the user-selected workspace-wide drafts +- Users can inspect, discard one, discard all, export, and recover drafts +- Storage failures and schema upgrades produce actionable UX +- E2E covers unmounted scopes, restart, stale conflict, and recovery + +## Implementation Notes + +After typed contract; final Apply path depends on preview/CAS. diff --git a/.frontloop/workbench-hardening/ready/0090-bound-workbench-caches-and-make-freshness-complete.md b/.frontloop/workbench-hardening/ready/0090-bound-workbench-caches-and-make-freshness-complete.md new file mode 100644 index 0000000..bb794e5 --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0090-bound-workbench-caches-and-make-freshness-complete.md @@ -0,0 +1,20 @@ +--- +title: Bound Workbench caches and make freshness complete +priority: high +--- + +## Goal + +Replace unbounded three-deck-per-selection caching and all-target cold media composition with bounded, shared, dependency-complete data structures. + +## Acceptance Criteria + +- Selection caches use a measured LRU/single-active policy and Arc sharing +- Transitive includes and locked dependencies participate in freshness +- Media catalogs do not compose every target on first request +- A 93-selection UG traversal stays within an approved RSS budget +- Cold/warm latency budgets and invalidation tests are enforced + +## Implementation Notes + +After provenance-aware planner; preserve generation guards. diff --git a/.frontloop/workbench-hardening/ready/0100-correct-progress-failure-accessibility-and-lifecycle-ux.md b/.frontloop/workbench-hardening/ready/0100-correct-progress-failure-accessibility-and-lifecycle-ux.md new file mode 100644 index 0000000..b2e06c60 --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0100-correct-progress-failure-accessibility-and-lifecycle-ux.md @@ -0,0 +1,20 @@ +--- +title: Correct progress failure accessibility and lifecycle UX +priority: medium +--- + +## Goal + +Make deck-wide progress accurate and provide deterministic busy/error/status semantics, keyboard accessibility, and graceful shutdown/restart behavior. + +## Acceptance Criteria + +- Selected-note DOM counts cannot overwrite deck-wide progress +- Loading/busy/live/error state is announced semantically +- Interactive controls have labels, active-row state, and keyboard coverage +- Failure and local-storage error paths have native/unit and browser tests +- Server shutdown drains/rejects writes safely and restart surfaces transaction/draft recovery + +## Implementation Notes + +Can follow complete detail/draft workflows. diff --git a/.frontloop/workbench-hardening/ready/0110-remove-workbench-compatibility-pivots-and-certify-embedded-release-bundle.md b/.frontloop/workbench-hardening/ready/0110-remove-workbench-compatibility-pivots-and-certify-embedded-release-bundle.md new file mode 100644 index 0000000..95577e5 --- /dev/null +++ b/.frontloop/workbench-hardening/ready/0110-remove-workbench-compatibility-pivots-and-certify-embedded-release-bundle.md @@ -0,0 +1,20 @@ +--- +title: Remove Workbench compatibility pivots and certify embedded release bundle +priority: medium +--- + +## Goal + +Delete broad legacy routes after all clients use typed list/detail interfaces and prove the embedded shipped UI—not only dev assets—passes the complete browser suite. + +## Acceptance Criteria + +- Card/source/metadata/comparison compatibility pivots have no clients and are removed +- Route removal is reflected in the versioned contract and docs +- All 13+ hardened browser scenarios run against embedded release assets +- Asset freshness remains byte-checked +- Apply experimental/read-only containment is removed only when all safety exit criteria pass + +## Implementation Notes + +Final Workbench task. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c517aea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: npm + directory: /documentation + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0989551 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + quality-gates: + name: reusable quality and artifact gates + uses: ./.github/workflows/reusable-quality.yml + with: + # A PR's merge ref is mutable; test the immutable reviewed head instead. + target_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + # The interface is exercised on PRs without asking an untrusted fork for + # credentials or pretending that the checked-in UG fixture is a consumer. + require_representative_consumer: false diff --git a/.github/workflows/integrity-check.yml b/.github/workflows/integrity-check.yml deleted file mode 100644 index 5cbe257..0000000 --- a/.github/workflows/integrity-check.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Python application - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - - name: Set up Python 3.7 - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - - name: Install dependencies - run: | - python3 -m pip install --upgrade pipenv - pipenv install --dev - - - name: Run tests - run: | - pipenv run unit_tests - - - name: Build Yamale Recipe - run: | - pipenv run build_yamale - - - name: Check Yamale Recipe for changes - run: git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run 'python scripts/yamale_build.py'" && exit 1) - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..aeada4a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +# cargo-dist generated the original release graph. This checked-in graph keeps its +# artifact matrix but replaces generated mutable installers with reviewed local +# tooling; no cargo-dist hosting command may run before quality-gates. +name: Release + +on: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +permissions: + contents: read + +# GitHub's keyless OIDC subject format is the workflow path plus ref. Every +# bundle verification below requires this release workflow and a tag ref, not +# merely any workflow in this repository. +env: + SIGSTORE_CERTIFICATE_IDENTITY_REGEX: '^https://github\.com/jeprecated/brain-brew/\.github/workflows/release\.yml@refs/tags/' + +jobs: + quality-gates: + name: release quality and artifact gates + uses: ./.github/workflows/reusable-quality.yml + with: + target_sha: ${{ github.sha }} + # This remains intentionally blocked until the separate live Ultimate + # Geography consumer workflow supplies HTTPS evidence and its checksum. + require_representative_consumer: true + representative_consumer_evidence_url: ${{ vars.REPRESENTATIVE_CONSUMER_EVIDENCE_URL }} + representative_consumer_evidence_sha256: ${{ vars.REPRESENTATIVE_CONSUMER_EVIDENCE_SHA256 }} + + plan: + needs: + - quality-gates + runs-on: ubuntu-22.04 + outputs: + manifest: ${{ steps.plan.outputs.manifest }} + tag: ${{ github.ref_name }} + tag-flag: ${{ format('--tag={0}', github.ref_name) }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ needs.quality-gates.outputs.target-sha }} + persist-credentials: false + submodules: recursive + - name: Verify the immutable gate SHA is the annotated or lightweight tag target + env: + TARGET_SHA: ${{ needs.quality-gates.outputs.target-sha }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$(git rev-parse "refs/tags/$TAG^{}")" = "$TARGET_SHA" + - name: Install checksum-verified cargo-dist + run: scripts/install_cargo_dist.sh "$HOME/.cargo/bin" + - id: plan + run: | + # The release workflow is intentionally post-generated to add + # fail-closed gates, so cargo-dist must not replace it at runtime. + dist plan --allow-dirty --output-format=json > plan-dist-manifest.json + echo "manifest=$(jq -c '.' plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: Upload dist plan + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6; documentation/docs/reference/release-security.md + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + needs: + - quality-gates + - plan + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.manifest).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + id-token: write + attestations: write + env: + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + METADATA_DIR: target/distrib/release-metadata-${{ join(matrix.targets, '_') }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ needs.quality-gates.outputs.target-sha }} + persist-credentials: false + submodules: recursive + - name: Install checksum-verified cargo-dist + run: scripts/install_cargo_dist.sh "$HOME/.cargo/bin" + - name: Install pinned keyless signing client + uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0; documentation/docs/reference/release-security.md + with: + cosign-release: v2.5.3 + - name: Fetch plan + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7; documentation/docs/reference/release-security.md + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + name: Build artifacts + shell: bash + run: | + dist build --allow-dirty ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo 'paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo EOF >> "$GITHUB_OUTPUT" + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + mapfile -t upload_files < <(dist print-upload-files-from-manifest --manifest dist-manifest.json) + metadata_args=() + subject_count=0 + echo 'artifact-subjects<> "$GITHUB_OUTPUT" + for artifact in "${upload_files[@]}"; do + case "$artifact" in + target/distrib/*) ;; + *) echo "refusing non-distribution artifact subject: $artifact" >&2; exit 1 ;; + esac + test -f "$artifact" + metadata_args+=(--artifact "$artifact") + # cargo-dist checksum files and our generated metadata are evidence, + # not shipped artifact subjects. All remaining upload files are the + # archive, binary, or installer bytes release consumers receive. + case "$artifact" in *.sha256|*.sha256sum|*.sbom.cdx.json|*.provenance.json|*.sigstore.json|*/SHA256SUMS) continue ;; esac + printf '%s\n' "$artifact" >> "$GITHUB_OUTPUT" + subject_count=$((subject_count + 1)) + done + echo EOF >> "$GITHUB_OUTPUT" + test "$subject_count" -gt 0 + python3 scripts/generate_release_metadata.py "${metadata_args[@]}" --output "$METADATA_DIR" --source-sha '${{ needs.quality-gates.outputs.target-sha }}' --workflow-run '${{ github.run_id }}' + python3 scripts/generate_release_metadata.py "${metadata_args[@]}" --output "$METADATA_DIR" --source-sha '${{ needs.quality-gates.outputs.target-sha }}' --workflow-run '${{ github.run_id }}' --verify + cosign sign-blob --yes --bundle "$METADATA_DIR/SHA256SUMS.sigstore.json" "$METADATA_DIR/SHA256SUMS" + cosign verify-blob --offline --bundle "$METADATA_DIR/SHA256SUMS.sigstore.json" --certificate-identity-regexp "$SIGSTORE_CERTIFICATE_IDENTITY_REGEX" --certificate-oidc-issuer https://token.actions.githubusercontent.com "$METADATA_DIR/SHA256SUMS" + - name: Keylessly attest produced shipped artifacts + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3; documentation/docs/reference/release-security.md + with: + subject-path: ${{ steps.cargo-dist.outputs.artifact-subjects }} + - name: Upload built artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6; documentation/docs/reference/release-security.md + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + ${{ env.METADATA_DIR }} + + build-global-artifacts: + needs: + - quality-gates + - plan + - build-local-artifacts + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + attestations: write + env: + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + METADATA_DIR: target/distrib/release-metadata-global + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ needs.quality-gates.outputs.target-sha }} + persist-credentials: false + submodules: recursive + - name: Install checksum-verified cargo-dist + run: scripts/install_cargo_dist.sh "$HOME/.cargo/bin" + - name: Install pinned keyless signing client + uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0; documentation/docs/reference/release-security.md + with: + cosign-release: v2.5.3 + - name: Fetch local artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7; documentation/docs/reference/release-security.md + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build --allow-dirty ${{ needs.plan.outputs.tag-flag }} --output-format=json --artifacts=global > dist-manifest.json + echo 'paths<> "$GITHUB_OUTPUT" + jq --raw-output '.upload_files[]' dist-manifest.json >> "$GITHUB_OUTPUT" + echo EOF >> "$GITHUB_OUTPUT" + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + mapfile -t upload_files < <(jq --raw-output '.upload_files[]' dist-manifest.json) + metadata_args=() + subject_count=0 + echo 'artifact-subjects<> "$GITHUB_OUTPUT" + for artifact in "${upload_files[@]}"; do + case "$artifact" in + target/distrib/*) ;; + *) echo "refusing non-distribution artifact subject: $artifact" >&2; exit 1 ;; + esac + test -f "$artifact" + metadata_args+=(--artifact "$artifact") + case "$artifact" in *.sha256|*.sha256sum|*.sbom.cdx.json|*.provenance.json|*.sigstore.json|*/SHA256SUMS) continue ;; esac + printf '%s\n' "$artifact" >> "$GITHUB_OUTPUT" + subject_count=$((subject_count + 1)) + done + echo EOF >> "$GITHUB_OUTPUT" + test "$subject_count" -gt 0 + python3 scripts/generate_release_metadata.py "${metadata_args[@]}" --output "$METADATA_DIR" --source-sha '${{ needs.quality-gates.outputs.target-sha }}' --workflow-run '${{ github.run_id }}' + python3 scripts/generate_release_metadata.py "${metadata_args[@]}" --output "$METADATA_DIR" --source-sha '${{ needs.quality-gates.outputs.target-sha }}' --workflow-run '${{ github.run_id }}' --verify + cosign sign-blob --yes --bundle "$METADATA_DIR/SHA256SUMS.sigstore.json" "$METADATA_DIR/SHA256SUMS" + cosign verify-blob --offline --bundle "$METADATA_DIR/SHA256SUMS.sigstore.json" --certificate-identity-regexp "$SIGSTORE_CERTIFICATE_IDENTITY_REGEX" --certificate-oidc-issuer https://token.actions.githubusercontent.com "$METADATA_DIR/SHA256SUMS" + - name: Keylessly attest produced shipped artifacts + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3; documentation/docs/reference/release-security.md + with: + subject-path: ${{ steps.cargo-dist.outputs.artifact-subjects }} + - name: Upload built artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6; documentation/docs/reference/release-security.md + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + ${{ env.METADATA_DIR }} + + host: + # Default GitHub Actions dependency semantics skip this job if any required + # gate is failed, cancelled, or skipped. + needs: + - quality-gates + - plan + - build-local-artifacts + - build-global-artifacts + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ needs.quality-gates.outputs.target-sha }} + persist-credentials: false + submodules: recursive + - name: Assert evidence and release checkout use the same SHA + env: + TARGET_SHA: ${{ needs.quality-gates.outputs.target-sha }} + EVIDENCE_SHA256: ${{ needs.quality-gates.outputs.evidence-sha256 }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + test "$EVIDENCE_SHA256" != '' + - name: Install checksum-verified cargo-dist + run: scripts/install_cargo_dist.sh "$HOME/.cargo/bin" + - name: Install pinned keyless signing client + uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0; documentation/docs/reference/release-security.md + with: + cosign-release: v2.5.3 + - name: Fetch produced artifacts only + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7; documentation/docs/reference/release-security.md + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Verify produced checksums, SBOMs, and workflow-bound provenance offline + env: + TARGET_SHA: ${{ needs.quality-gates.outputs.target-sha }} + WORKFLOW_RUN: ${{ github.run_id }} + run: | + set -euo pipefail + found=0 + while IFS= read -r provenance; do + artifact_name="$(jq -r '.subject[0].name' "$provenance")" + artifact="$(find target/distrib -type f -name "$artifact_name" ! -path '*/release-metadata-*/*' -print -quit)" + test -n "$artifact" + python3 scripts/generate_release_metadata.py --verify --artifact "$artifact" --output "$(dirname "$provenance")" --source-sha "$TARGET_SHA" --workflow-run "$WORKFLOW_RUN" + found=1 + done < <(find target/distrib -type f -name '*.provenance.json' | sort) + test "$found" = 1 + while IFS= read -r checksum; do + cosign verify-blob --offline --bundle "$checksum.sigstore.json" --certificate-identity-regexp "$SIGSTORE_CERTIFICATE_IDENTITY_REGEX" --certificate-oidc-issuer https://token.actions.githubusercontent.com "$checksum" + done < <(find target/distrib -type f -name SHA256SUMS | sort) + - id: host + name: Upload release artifacts + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + dist host --allow-dirty ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "manifest=$(jq -c '.' dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: Create GitHub Release from verified produced artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: ${{ needs.quality-gates.outputs.target-sha }} + run: | + set -euo pipefail + printf '%s\n' "$ANNOUNCEMENT_BODY" > "$RUNNER_TEMP/notes.txt" + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" target/distrib/* + mapfile -t metadata_assets < <(find target/distrib -type f \( -name 'SHA256SUMS' -o -name '*.sbom.cdx.json' -o -name '*.provenance.json' -o -name '*.sigstore.json' \) | sort) + test "${#metadata_assets[@]}" -gt 0 + gh release upload "${{ needs.plan.outputs.tag }}" --clobber "${metadata_assets[@]}" diff --git a/.github/workflows/reusable-quality.yml b/.github/workflows/reusable-quality.yml new file mode 100644 index 0000000..46e3262 --- /dev/null +++ b/.github/workflows/reusable-quality.yml @@ -0,0 +1,169 @@ +name: Reusable quality and artifact gates + +on: + workflow_call: + inputs: + target_sha: + description: Full immutable commit SHA to verify. + required: true + type: string + require_representative_consumer: + description: Require live Ultimate Geography consumer evidence (release only). + required: true + type: boolean + representative_consumer_evidence_url: + description: HTTPS URL for independently generated ultimate-geography-live evidence. + required: false + default: '' + type: string + representative_consumer_evidence_sha256: + description: SHA-256 binding the downloaded live-consumer evidence bytes. + required: false + default: '' + type: string + outputs: + target-sha: + description: Verified immutable SHA used by every gate. + value: ${{ jobs.validate-input.outputs.target-sha }} + evidence-sha256: + description: SHA-256 of the evidence bundle bound to target-sha. + value: ${{ jobs.quality.outputs.evidence-sha256 }} + representative-consumer-status: + description: passed or blocked; releases require passed. + value: ${{ jobs.quality.outputs.representative-consumer-status }} + +permissions: + contents: read + +jobs: + validate-input: + name: validate immutable target + runs-on: ubuntu-22.04 + outputs: + target-sha: ${{ steps.sha.outputs.target-sha }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ inputs.target_sha }} + persist-credentials: false + fetch-depth: 0 + - id: sha + name: Refuse mutable refs and absent commits + env: + TARGET_SHA: ${{ inputs.target_sha }} + run: | + set -euo pipefail + if ! [[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "target_sha must be a full immutable commit SHA" >&2 + exit 1 + fi + resolved="$(git rev-parse --verify "${TARGET_SHA}^{commit}")" + test "$resolved" = "$TARGET_SHA" + echo "target-sha=$resolved" >> "$GITHUB_OUTPUT" + + quality: + name: quality, artifacts, and consumer contract + needs: validate-input + runs-on: ubuntu-22.04 + timeout-minutes: 90 + outputs: + evidence-sha256: ${{ steps.evidence.outputs.evidence-sha256 }} + representative-consumer-status: ${{ steps.consumer.outputs.status }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6; documentation/docs/reference/release-security.md + with: + ref: ${{ inputs.target_sha }} + persist-credentials: false + submodules: recursive + # Do not pass a token or cache credential: this same gate executes for + # untrusted pull requests and has no publication side effects. + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31; documentation/docs/reference/release-security.md + - name: Install devenv from this repository's locked nixpkgs input + run: nix profile add --inputs-from . nixpkgs#devenv + - name: Run format, tests, lint, and embedded-asset gate + run: devenv shell ci + - name: Enforce Cargo and production npm advisory and license policy + # This has no credentials and records raw audit reports plus the exact + # installed package-license inventory in the evidence bundle. + run: devenv shell supply-chain:check + - name: Build documentation from the audited lockfile + run: npm --prefix documentation run build + - name: Verify extracted publication archives + run: python3 scripts/verify_extracted_crates.py pre-publish --evidence-dir target/release-evidence/extracted + - name: Install and smoke only a produced crate archive + run: python3 scripts/package_artifact_smoke.py --target-sha '${{ needs.validate-input.outputs.target-sha }}' --workflow-run '${{ github.run_id }}' --evidence-dir target/release-evidence/package-smoke + - name: Build and smoke the Nix output + run: | + set -euo pipefail + nix build .#checks.x86_64-linux.brainbrew -L --no-link + nix_output="$(nix build .#brainbrew --print-out-paths --no-link)" + test -x "$nix_output/bin/brainbrew" + scripts/release_smoke.sh "$nix_output/bin/brainbrew" + printf '%s\n' "$nix_output" > target/release-evidence/nix-output-path.txt + python3 scripts/generate_release_metadata.py \ + --artifact "$nix_output/bin/brainbrew" \ + --output target/release-evidence/nix-sbom \ + --source-sha '${{ needs.validate-input.outputs.target-sha }}' \ + --workflow-run '${{ github.run_id }}' + python3 scripts/generate_release_metadata.py \ + --artifact "$nix_output/bin/brainbrew" \ + --output target/release-evidence/nix-sbom \ + --source-sha '${{ needs.validate-input.outputs.target-sha }}' \ + --workflow-run '${{ github.run_id }}' \ + --verify + - name: Run prepared Chromium Workbench E2E + run: devenv shell e2e + - name: Build and smoke a produced cargo-dist archive + run: | + set -euo pipefail + version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["package"]["version"])')" + # The host archive is sufficient artifact evidence here; the release + # matrix builds each configured platform after this gate passes. + devenv shell -- bash -c "dist build --allow-dirty --tag v$version --artifacts=local --target x86_64-unknown-linux-gnu --output-format=json > target/release-evidence/dist-manifest.json" + python3 scripts/smoke_release_archive.py \ + --manifest target/release-evidence/dist-manifest.json \ + --version "$version" \ + --target-sha '${{ needs.validate-input.outputs.target-sha }}' \ + --workflow-run '${{ github.run_id }}' \ + --evidence-dir target/release-evidence + - id: consumer + name: representative-consumer contract + env: + REQUIRED: ${{ inputs.require_representative_consumer }} + EVIDENCE_URL: ${{ inputs.representative_consumer_evidence_url }} + EVIDENCE_SHA256: ${{ inputs.representative_consumer_evidence_sha256 }} + TARGET_SHA: ${{ needs.validate-input.outputs.target-sha }} + run: | + set -euo pipefail + required_flag='' + if test "$REQUIRED" = true; then required_flag=--required; fi + python3 scripts/verify_representative_consumer.py \ + --target-sha "$TARGET_SHA" \ + --url "$EVIDENCE_URL" \ + --sha256 "$EVIDENCE_SHA256" \ + $required_flag \ + --output target/release-evidence/representative-consumer.json + echo "status=$(jq -r .status target/release-evidence/representative-consumer.json)" >> "$GITHUB_OUTPUT" + - id: evidence + name: Bind evidence bundle to the immutable SHA + env: + TARGET_SHA: ${{ needs.validate-input.outputs.target-sha }} + run: | + set -euo pipefail + python3 - "$TARGET_SHA" <<'PY' + import hashlib, json, pathlib, sys + root = pathlib.Path("target/release-evidence") + files = {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(root.rglob("*")) if path.is_file()} + bundle = {"schema_version": 1, "target_sha": sys.argv[1], "files": files} + encoded = (json.dumps(bundle, sort_keys=True, indent=2) + "\n").encode() + (root / "evidence.json").write_bytes(encoded) + print(hashlib.sha256(encoded).hexdigest()) + PY + evidence_sha256="$(sha256sum target/release-evidence/evidence.json | cut -d ' ' -f 1)" + echo "evidence-sha256=$evidence_sha256" >> "$GITHUB_OUTPUT" + - name: Upload cryptographically bound evidence bundle + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6; documentation/docs/reference/release-security.md + with: + name: quality-evidence-${{ needs.validate-input.outputs.target-sha }} + path: target/release-evidence + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 1506eb0..35b2249 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,31 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Rust +/target/ -# C extensions -*.so +# Devenv local overrides/state +/.devenv/ +devenv.local.nix +devenv.local.yaml -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py +# Editor/OS noise +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo -# Environments +# Local environment .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject +.env.* +!.env.example -# Rope project settings -.ropeproject +# Nix build outputs +/result +/result-* -# mkdocs documentation -/site +# Documentation site generated files +/documentation/.docusaurus/ +/documentation/build/ +/documentation/node_modules/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - - - -.idea/ -.directory \ No newline at end of file +# Local generated/downloaded caches +/.cache/ diff --git a/.sd/aggregates.yaml b/.sd/aggregates.yaml new file mode 100644 index 0000000..891479f --- /dev/null +++ b/.sd/aggregates.yaml @@ -0,0 +1,66 @@ +version: 1 + +tasks: + release/crates/all: + desc: Publish brain-brew-core, brain-brew-formats, then brainbrew to crates.io + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} all{{confirmArgs}} + + release/crates/core: + desc: Publish brain-brew-core to crates.io + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} core{{confirmArgs}} + + release/crates/formats: + desc: Publish brain-brew-formats to crates.io after brain-brew-core is indexed + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} formats{{confirmArgs}} + + release/crates/cli: + desc: Publish brainbrew to crates.io after brain-brew-formats is indexed + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} cli{{confirmArgs}} diff --git a/.sd/tasks.yaml b/.sd/tasks.yaml new file mode 100644 index 0000000..84cf829 --- /dev/null +++ b/.sd/tasks.yaml @@ -0,0 +1,72 @@ +version: 1 + +project: + id: brain-brew + name: Brain Brew + aliases: + - brainbrew + workArea: Deck federation + visibility: public-code / CLI workspace + dir: . + +tasks: + check: + desc: Run the standard Brain Brew validation gate + run: devenv shell ci + + build/debug: + desc: Build the brainbrew CLI debug binary + run: devenv shell cargo build -p brainbrew + + build/release: + desc: Build the brainbrew CLI release binary + run: devenv shell cargo build -p brainbrew --release + + run/help: + desc: Run the brainbrew CLI help through cargo + run: devenv shell brainbrew --help + + run/ug/targets: + desc: List Ultimate Geography manifest targets + run: devenv shell brainbrew targets --manifest fixtures/ultimate-geography/brainbrew.yaml + + run/ug/compose/en-standard: + desc: Compose the Ultimate Geography en-standard target to target/sd/ug-en-standard.yaml + run: | + set -euo pipefail + mkdir -p target/sd + devenv shell brainbrew compose --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard --out target/sd/ug-en-standard.yaml + + run/ug/export/en-standard: + desc: Export the hashless structural fixture (not release or byte-integrity evidence) to target/sd/crowdanki/en-standard + run: devenv shell brainbrew export crowdanki --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard --out target/sd/crowdanki/en-standard --media-mode reference-only + + run/ug/verify/all: + desc: Verify every target in the hashless structural fixture (not release or byte-integrity evidence) + run: devenv shell brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets --media-mode reference-only + + workbench/ug: + desc: Build Workbench dev assets and serve the Ultimate Geography manifest; pass -- --no-open or -- --port as needed + run: | + set -euo pipefail + if [ "${1:-}" = "--" ]; then + shift + fi + devenv shell workbench-ui-build + media_args=() + default_media_root="../../external/ultimate-geography/media" + media_root="${BRAINBREW_UG_MEDIA_ROOT:-$default_media_root}" + if [ -d "$media_root" ]; then + media_args=(--media-root "$media_root") + else + echo "warning: UG media root not found at $media_root; set BRAINBREW_UG_MEDIA_ROOT to override" >&2 + fi + devenv shell brainbrew workbench serve --manifest fixtures/ultimate-geography/brainbrew.yaml --dev-assets target/workbench-ui "${media_args[@]}" "$@" + +passthrough: + cargo: + desc: Run cargo in the Brain Brew workspace + run: cargo "$@" + devenv: + desc: Run devenv in the Brain Brew workspace + run: devenv "$@" diff --git a/.sd/workspace.yaml b/.sd/workspace.yaml new file mode 100644 index 0000000..15fb746 --- /dev/null +++ b/.sd/workspace.yaml @@ -0,0 +1,8 @@ +version: 1 + +workspace: + id: brain-brew + name: Brain Brew + +discover: + - .sd/tasks.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2f629ad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Agent Guidance + +## Project Aim + +Brain Brew is a Rust-based, local-first deck federation tool for shared Anki-compatible decks. The first milestone is not a web app, SaaS, live sync tool, legacy Python recipe compatibility, or full Ultimate Geography clone. + +Read these before making design changes: + +- `CONTEXT.md` — domain glossary +- `documentation/docs/reference/project-scope.md` — current scope and architecture boundaries +- `documentation/docs/reference/decisions/README.md` — active ADR index + +Use the project skill `skills/federated-deck-extensions/SKILL.md` whenever creating, reviewing, or refactoring Federated Deck source, translation overlays, extension overlays, field fills, or UG-style variant targets. It captures the variable-first/shared-extension workflow and common mistakes to avoid. + +## Development Method + +Use TDD with Red-Green-Refactor: + +1. Add a failing test for the next behavior. +2. Implement the smallest change that passes. +3. Refactor with tests still green. + +Scaffolding can exist without tests, but domain behavior, format behavior, adapter behavior, and CLI behavior should enter through a failing test. + +## Crate Boundaries + +- `brain-brew-core`: pure domain only. No YAML, CrowdAnki, filesystem, terminal, or CLI dependencies. +- `brain-brew-formats`: reusable YAML/CrowdAnki codecs over core types. +- `brainbrew`: thin command-line package in `crates/brain-brew-cli`, filesystem access, prompts, and report rendering. + +## Commands + +Use Devenv: + +```bash +devenv shell fmt +devenv shell test +devenv shell clippy +devenv shell ci +``` + +Run `devenv test` before committing meaningful code changes. + +The Devenv shell defaults Rust test execution and Cargo compilation to two +parallel workers (`RUST_TEST_THREADS=2` and `CARGO_BUILD_JOBS=2`). These +cooler defaults intentionally trade longer runtimes for lower CPU and thermal +load, and apply to the named scripts as well as focused Cargo commands run +through `devenv shell`. Override either setting for one invocation when more +throughput is appropriate: + +```bash +RUST_TEST_THREADS=8 CARGO_BUILD_JOBS=8 devenv shell test +RUST_TEST_THREADS=1 CARGO_BUILD_JOBS=4 devenv shell -- cargo test -p brain-brew-core +``` + +Run `devenv shell check:rust-parallelism` to verify both the defaults and the +override path in real nested Devenv shells. + +## Version Control + +This repo uses Jujutsu. Use `jj status`, `jj diff`, and `jj commit`; do not use direct `git` workflow commands. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c083d3e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## Unreleased + +The next preview is `1.0.0-alpha.2`; it is pending the documented release gates and manual crates.io publication. + +- Recursive package-root discovery is now deterministic, no-follow, and registry-owned, with exact built-in VCS/build/output/cache pruning, authorized component-glob ignores, explicit depth/entry/manifest budgets, structured actionable errors, and JSON visit/prune statistics shared by every package-root command. +- Media-bearing `verify` and manifest/ad-hoc CrowdAnki export are now release-strict by default: every final package owner needs an explicit media root, hashes must be canonical lowercase SHA-256, and bytes must exist and match before clean-tree publication. Hashless/source-only fixtures must migrate to explicit `--media-mode reference-only`, whose human/JSON status is always marked not release-ready when media is present. +- Structured `!image` rendering percent-encodes hostile filenames and HTML-attribute escapes output without changing declaration/CrowdAnki filenames; media paths now reject control, URL/platform, bidi/format-control, and reserved-name ambiguity, while scanners decode normalized HTML/URL references before comparison. +- Package federation now validates the complete root/include/package-root/lock registry before planning: package versions and exact pins use SemVer, explicit base compatibility ranges are enforced, dependency cycles include full deterministic edge traces, and overlay catalog IDs/kinds must match decoded sources. +- CrowdAnki import now fails closed on additional unsupported or ambiguous data, including `bafmt`/`bqfmt`/`did` fields and media stable-ID collisions, while preserving media-file parity. +- Canonical YAML emission is safer for block scalars, CRLF-sensitive content, and map keys, with validated/quoted keys and hard failures for un-emittable key text. +- Overlay field operations are fill-blank-only. Full `note:`/`note_type:` bodies require explicit `add`, or fingerprint-protected `replace`/`override`; merge remains sparse. +- Complete note, note-type, field-definition, card-template, and media destructive operations now compare stable `sha256:v1` canonical entity fingerprints. Presence-only expected bases are rejected, `diff --as-overlay` generates fingerprints, and explain JSON exposes typed expected/actual precondition details. +- Package verification detects drift in local path sources, and path locks are stored as portable paths relative to `brainbrew.lock`. +- Translation coverage now surfaces untranslated structured-message `format` glue strings, and stale-translation resolution removes shadowed stale records cleanly. +- `validate --json`, `explain --json`, `diff --json`, and `targets --json` now emit machine-parseable failure envelopes on stdout with non-zero exits, empty stderr, and structured `error.errors[]` details where available. + +## v1.0.0-alpha.1 + +Initial Rust-based Brain Brew preview release. + +- Adds the `brainbrew` CLI for Canonical Deck validation, formatting, composition, semantic diffing, and CrowdAnki import/export. +- Adds Federated Deck manifests, named targets, package-qualified composition, package locks, and CI-friendly `verify` checks. +- Adds media reference validation and release-oracle comparison support for parity reviews. +- Includes Ultimate Geography-style fixtures used to validate translations, variants, and Hardcore Geography extension overlays. +- Ships prebuilt release archives, shell and PowerShell installers, and a Homebrew formula via `cargo-dist`. +- `1.0.0-alpha.1` was published with interfaces incompatible with `1.0.0-alpha.2`; use the current preview rather than treating alpha.1 as compatible with this source. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..4ff996d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,268 @@ +# Brain Brew + +Brain Brew exists to help flashcard deck maintainers compose, evolve, and redistribute decks without losing the structure or history that makes those decks useful. + +## Language + +**Brain Brew**: +A local-first deck federation tool for flashcard decks. +_Avoid_: universal note sync service, SaaS sync platform + +**Deck**: +A shareable flashcard collection including notes, note types, card templates, styling, metadata, and media references. +_Avoid_: note list, CSV file, Anki export + +**Deck Maintainer**: +A person responsible for evolving and publishing a shared flashcard deck. +_Avoid_: learner, reviewer, end user + +**Deck Workbench**: +An ergonomic authoring and review interface for working with a Federated Deck workspace without treating the interface as canonical storage. +_Avoid_: web app source of truth, generated HTML report, translator-only app + +**Learner**: +A person who studies with a shared deck and may have private changes to preserve. +_Avoid_: deck maintainer, publisher + +**Shared Deck**: +A flashcard deck intended to be installed, updated, or extended by people other than its maintainer. +_Avoid_: personal deck, private notes + +**Federated Deck**: +A composable source package for a shared deck contribution, containing a base deck, overlays, or both, intended to be composed with other Federated Decks. +_Avoid_: Anki subdeck, resolved deck, full deck copy + +**Canonical Deck**: +The format-independent representation of a deck's notes, note types, card templates, styling, metadata, and media references. +_Avoid_: canonical note list, CrowdAnki JSON + +**Canonical Deck File**: +The maintainer-owned source file containing a canonical deck. +_Avoid_: generated artifact, adapter export + +**Note**: +A deck entity containing field values and tags for one learnable fact or item. +_Avoid_: card, row + +**Note Type**: +A deck entity that defines the fields and card templates shared by a group of notes. +_Avoid_: template, card type + +**Card Template**: +A deck entity that defines how a note becomes a study card. +_Avoid_: note type, card + +**Card**: +A study item produced from a note through a card template. +_Avoid_: note, template + +**Review History**: +A learner's scheduling and study progress for cards in a spaced repetition system. +_Avoid_: deck content, note metadata + +**Media Asset**: +An external file used by deck content or presentation. +_Avoid_: embedded YAML data, field text + +**Media Reference**: +A deck entity that identifies and verifies a media asset. +_Avoid_: raw media file, HTML snippet + +**Deck Entity**: +An identifiable part of a deck, such as a note, note type, card template, media reference, or deck metadata item. +_Avoid_: file, row, JSON object + +**Stable ID**: +A human-readable identifier that says a deck entity is the same entity across source files, overlays, exports, and releases. +_Avoid_: content hash, row number, display name, adapter GUID + +**Adapter ID**: +An identifier used by an external deck format or tool for the same deck entity. +_Avoid_: stable ID, content hash + +**Suggested Stable ID**: +A proposed stable ID generated during import that must be accepted or corrected before becoming canonical. +_Avoid_: stable ID, adapter ID + +**Content Hash**: +A fingerprint of deck content used to detect changes, not to identify entities. +_Avoid_: note ID, canonical ID + +**Overlay**: +A bounded set of changes applied to a base deck without replacing the base deck. +_Avoid_: fork, duplicate deck + +**Source Language**: +The language of the base deck text used as the reference for translation work. +_Avoid_: hard-coded English assumption, target language + +**Target Language**: +A language produced from the source language by a Translation Overlay. +_Avoid_: separate translated deck, independent deck language + +**Translation Overlay**: +An overlay that changes deck language or localized text. +_Avoid_: separate translated deck + +**Extension Overlay**: +An overlay that adds new deck content or structure. +_Avoid_: patch, translation + +**Patch Overlay**: +An overlay that corrects or adjusts existing deck content or structure. +_Avoid_: extension, fork + +**Personal Overlay**: +An overlay containing learner-specific deck content or structure that should survive shared deck updates. +_Avoid_: upstream deck, maintainer patch, study state + +**Overlay Fragment**: +The sparse deck-shaped content of an overlay, containing only the deck entities and properties the overlay changes. +_Avoid_: full deck copy, command script + +**Source Variable**: +A named text value defined on a deck, note type, card template, or note and referenced from source text with `${variable.name}` before adapter export. +_Avoid_: recipe variable, runtime Anki field + +**Translation Dictionary**: +A translation overlay section for faithful target-language text: direct source text, contextual source text, explicit no-change decisions for reviewed unchanged text, source variables, and adapter IDs, with non-blank source keys acting as implicit expected bases. +_Avoid_: CSV importer, global localization database, target adaptation storage + +**Target Adaptation**: +A typed path-scoped target-language adaptation or deletion that declares its intent, translation ownership, expected source value, and review reason. +_Avoid_: faithful translation, extension field fill, implicit global deletion + +**Stale Translation**: +A translation review item that applies a prior target text to changed source text while warning that the translation needs review. +_Avoid_: stale key only, automatic silent migration + +**Field Fill**: +An overlay shorthand for filling existing blank note fields with new content while requiring the upstream field to still be blank. +_Avoid_: translation addition, field definition addition + +**Change Intent**: +The declared meaning of an overlay change, such as add, merge, replace, remove, or override. +_Avoid_: implicit overwrite, accidental merge + +**Tombstone**: +A record that a deck entity was deliberately removed. +_Avoid_: missing data, accidental deletion + +**Expected Base**: +The prior deck value or fingerprint an overlay declares before making a destructive or conflict-resolving change. +_Avoid_: current value guess, unchecked overwrite + +**Overlay Stack**: +An ordered set of overlays applied to a base deck. +_Avoid_: unordered overlay set, dependency graph + +**Overlay Catalog**: +A named collection of overlays available in a Federated Deck. +_Avoid_: raw file list, package solver + +**Language Catalog**: +A named collection of source and target languages available in a Federated Deck, connecting target languages to their Translation Overlays and Build Targets. +_Avoid_: target naming convention, hard-coded language list + +**Overlay Dependency**: +A requirement that one overlay include and apply another overlay before itself. +_Avoid_: implicit conflict resolution, automatic content merge + +**Build Target**: +A named composition goal that resolves a base deck and selected overlays into a Resolved Deck. +_Avoid_: Anki export, source file, recipe step + +**Resolved Deck**: +The deck produced by applying an overlay stack to a base deck. +_Avoid_: build artifact, export format + +**Compose**: +To produce a resolved deck by applying an overlay stack to a base deck. +_Avoid_: build, export + +**Semantic Diff**: +A comparison of decks by stable IDs and deck entities rather than raw source lines. +_Avoid_: text diff, file diff + +**Federation Conflict**: +A situation where overlays make incompatible changes to the same deck entity. +_Avoid_: validation warning, last write wins + +**Deck Federation**: +The composition of a base deck with translations, extensions, patches, or personal overlays without copying the whole deck. +_Avoid_: fork, duplicate deck, one-off conversion + +**Canonicalized Source**: +A source representation after Brain Brew has applied its deterministic formatting rules. +_Avoid_: arbitrary original bytes, hand-formatted source + +**Adapter Equivalence**: +A test projection that compares the supported data shared by Canonical Deck and a distributable adapter format. +_Avoid_: promise of an Anki-to-source merge or source ownership recovery + +## Relationships + +- **Brain Brew** primarily serves **Deck Maintainers**. +- A **Deck Maintainer** publishes one or more **Shared Decks**. +- A **Deck Maintainer** may publish a **Federated Deck** as a composable shared-deck source package. +- A **Deck Workbench** helps people review or author a **Federated Deck** while preserving source files as canonical storage. +- A **Federated Deck** may contribute a base **Deck**, **Overlays**, or both. +- **Federated Decks** compose through **Deck Federation** to produce **Resolved Decks**. +- A **Learner** studies a **Shared Deck** and may have a **Personal Overlay**. +- **Brain Brew** works on **Decks**. +- A **Canonical Deck** represents one **Deck** without binding it to a source or distribution format. +- A **Canonical Deck File** is the source of truth for a **Canonical Deck**. +- A **Deck** contains **Deck Entities**. +- A **Note** belongs to one **Note Type**. +- A **Note Type** has one or more **Card Templates**. +- A **Card** is produced from one **Note** and one **Card Template**. +- **Review History** is preserved by stable identity, not stored as **Deck** content. +- A **Media Reference** points to one **Media Asset**. +- A **Stable ID** identifies a **Deck Entity** across source, overlays, exports, and bootstrap imports. +- An **Adapter ID** preserves identity in a specific external format or tool. +- A **Suggested Stable ID** becomes a **Stable ID** only after maintainer review. +- A **Content Hash** describes current content for change detection. +- **Deck Federation** combines one base **Deck** with zero or more **Overlays**. +- An **Overlay** contains an **Overlay Fragment**. +- An **Overlay** may use a **Translation Dictionary** to translate extracted source text without repeating per-field replacement boilerplate. +- An **Overlay** may contain **Stale Translations** when source text changes invalidate existing target-language decisions. +- An **Overlay** may use **Field Fills** to add content to existing blank note fields without misclassifying that content as a translation. +- A **Source Variable** lets shared card template structure refer to phrase values translated by a **Translation Dictionary**. +- An **Overlay** uses **Change Intents** to change **Deck Entities** by **Stable ID**. +- Replace, remove, and override **Change Intents** require an **Expected Base**. +- A remove **Change Intent** creates a **Tombstone**. +- An **Overlay Catalog** names the overlays available in a **Federated Deck**. +- A **Language Catalog** names the **Source Language** and **Target Languages** available in a **Federated Deck**. +- A **Target Language** is produced by applying a **Translation Overlay** to source-language content. +- An **Overlay Dependency** constrains the order of an **Overlay Stack**. +- An **Overlay Stack** applies overlays in declared or dependency-expanded order. +- A **Build Target** selects overlays from an **Overlay Catalog** for composition. +- **Compose** applies an **Overlay Stack** to a base **Deck** to produce a **Resolved Deck**. +- A **Semantic Diff** compares **Decks** through **Deck Entities** and **Stable IDs**. +- A **Federation Conflict** must be resolved explicitly. +- **Translation Overlays**, **Extension Overlays**, **Patch Overlays**, and **Personal Overlays** are kinds of **Overlay**. +- **Adapter Equivalence** compares only the data shared by Canonical Deck and a distributable form; it does not recover maintainer source structure. + +## Example dialogue + +> **Dev:** "If a translator adds German text to Ultimate Geography, are they creating a new independent deck?" +> **Domain expert:** "No — they are participating in **Deck Federation** by applying a translation overlay to the base **Deck**." + +## Flagged ambiguities + +- "sync tool" previously meant live bidirectional note-system synchronization; resolved: **Brain Brew** is first a local-first **Deck Federation** tool, not an Anki-to-source merge system. +- "canonical note" previously meant the central federation object; resolved: the central object is the **Canonical Deck**, because a **Deck** includes more than notes. +- "content hash" previously meant identity; resolved: a **Content Hash** detects change, while a **Stable ID** defines sameness. +- "Anki GUID" could mean canonical identity; resolved: Anki/CrowdAnki GUIDs are **Adapter IDs**, while human-readable **Stable IDs** identify canonical deck entities. +- "personal overlay" was used to mean both learner workflow and derivative change; resolved: a **Personal Overlay** is a derivative change, while a full learner workflow is not implied. +- "preserve Anki history" could mean storing review data; resolved: **Review History** remains outside **Canonical Deck** content and is preserved through stable identity. +- "media in the deck file" could mean embedded bytes; resolved: **Canonical Deck** stores **Media References**, while **Media Assets** remain external files. +- "overlay order" could imply last-write-wins; resolved: an **Overlay Stack** is ordered, but conflicting changes fail unless explicitly resolved. +- "byte-for-byte adapter equivalence" could mean preserving arbitrary input formatting; resolved: byte stability applies to **Canonicalized Source**. +- "CSV source" previously implied the maintainer source of truth; resolved: the **Canonical Deck File** is the source of truth, while CSV is an adapter format. +- "subdeck" could mean Anki deck hierarchy; resolved: composable source packages in a deck federation are **Federated Decks**, not Anki subdecks. +- "translated deck identity" could mean a separate stable identity per language; resolved: translations use language-neutral **Stable IDs** for the same conceptual **Deck Entities** and language-specific external identities remain **Adapter IDs**. +- "Ultimate Geography support" could mean product-specific application behavior; resolved: Ultimate Geography is a demanding case study and parity fixture for general Brain Brew federation behavior, not a special-purpose application feature. +- "migration import" could mean Brain Brew should convert every legacy source layout; resolved: initial migration means refactoring into **Canonical Deck Files** and proving output parity, not building public legacy source importers. +- "webview" and "Iced server" were used for an interactive editing surface; resolved: the product concept is a **Deck Workbench**, and it must not become a separate source of truth. +- "language" could mean either source text language or translated output language; resolved: use **Source Language** for the base text and **Target Language** for languages produced by **Translation Overlays**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5700b5f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ - -### Install Brain Brew package - -https://pypi.org/project/Brain-Brew/ - -```shell -pipenv install brain-brew -``` - -### Run Local Version - -Fork/Clone this repo onto your computer, then in a different repository you wish to run Brain Brew you can point it to this version in a 2 ways: - -#### Install development folder for live updates - -Point your installation to this folder. Run the following (change the path to match yours): - -```shell -pipenv install -e ../brain-brew -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew", editable = true} -``` - -#### Install a locally built package - -Build Brain Brew using the `scripts/build.bash` script. This will generate dist and build folders. Install the generated wheel by running: - -``` -pip install ../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl"} -``` - -Change to match the wheel version number, which is set in `brain_brew/front_matter.py` if you wish to change it. - - diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..15ca507 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3791 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brain-brew-core" +version = "1.0.0-alpha.2" +dependencies = [ + "sha2", +] + +[[package]] +name = "brain-brew-formats" +version = "1.0.0-alpha.2" +dependencies = [ + "base64", + "brain-brew-core", + "semver", + "serde", + "serde_json", + "serde_path_to_error", + "serde_yaml", + "sha2", + "unicode-normalization", +] + +[[package]] +name = "brain-brew-workbench-e2e" +version = "1.0.0-alpha.2" +dependencies = [ + "anyhow", + "tempfile", + "thirtyfour", + "tokio", +] + +[[package]] +name = "brain-brew-workbench-ui" +version = "1.0.0-alpha.2" +dependencies = [ + "console_error_panic_hook", + "gloo-net", + "leptos", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "brainbrew" +version = "1.0.0-alpha.2" +dependencies = [ + "axum", + "base64", + "brain-brew-core", + "brain-brew-formats", + "crossterm", + "flate2", + "fs2", + "include_dir", + "libc", + "nix-nar", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tar", + "tempfile", + "tokio", + "tower-http", + "ureq", + "url", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix 1.1.4", + "tokio", + "windows-sys 0.61.2", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom 0.4.2", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.18", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "nix-nar" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a60e6f4acddbfaa0c363f8bdc6e4b2188d0b690c567176212f795fe008a30b3" +dependencies = [ + "camino", + "is_executable", + "symlink", + "thiserror 2.0.18", +] + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.18", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringmatch" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aadc0801d92f0cdc26127c67c4b8766284f52a5ba22894f285e3101fa57d05d" +dependencies = [ + "regex", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thirtyfour" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c23ea3ddea759604ee20091e361347531cbdd5e4575435dc87e55ad7bcae5775" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "const_format", + "dirs", + "flate2", + "fs4", + "futures-util", + "http", + "indexmap", + "pastey", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "stringmatch", + "tar", + "thirtyfour-macros", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "zip", +] + +[[package]] +name = "thirtyfour-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf0ffc3ba4368e99597bd6afd83f4ff6febad66d9ae541ab46e697d32285fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..868ac52 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[workspace] +members = [ + "crates/brain-brew-core", + "crates/brain-brew-formats", + "crates/brain-brew-cli", + "crates/brain-brew-workbench-ui", + "crates/brain-brew-workbench-e2e", +] +resolver = "3" + +[workspace.package] +version = "1.0.0-alpha.2" +edition = "2024" +rust-version = "1.94" +license = "Unlicense" +authors = ["Brain Brew contributors"] +repository = "https://github.com/jeprecated/brain-brew" + +[workspace.dependencies] +brain-brew-core = { path = "crates/brain-brew-core", version = "=1.0.0-alpha.2" } +brain-brew-formats = { path = "crates/brain-brew-formats", version = "=1.0.0-alpha.2" } +semver = "1" +sha2 = "0.10" +unicode-normalization = "0.1" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4d17a07..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include brain_brew/schemas/recipe.yaml diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 3f71eac..0000000 --- a/Pipfile +++ /dev/null @@ -1,24 +0,0 @@ -[[source]] -name = "Brain Brew" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -pytest = "==5.4.1" -twine = "*" -coverage = "==4.5.4" -typing-extensions = "==3.10.0.0" - -[packages] -"ruamel.yaml" = "==0.16.10" -yamale = "==3.0.8" - -[requires] -python_version = "3.7" - -[scripts] -build_yamale = "python scripts/yamale_build.py" -check_for_changes = ''' - git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run `python scripts/yamale_build.py`" && exit 1) -''' -unit_tests = "py.test" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 5415fc1..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,645 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "0d4532926edfd30fdf44d37fea09d682ba1b3bab705b425f29745ec3cee0f7ac" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "Brain Brew", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "pyyaml": { - "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" - ], - "markers": "python_version >= '3.6'", - "version": "==6.0.1" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b", - "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954" - ], - "index": "Brain Brew", - "version": "==0.16.10" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d", - "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001", - "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462", - "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9", - "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe", - "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b", - "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b", - "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615", - "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62", - "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15", - "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b", - "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1", - "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9", - "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675", - "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899", - "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7", - "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7", - "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312", - "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa", - "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91", - "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b", - "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6", - "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3", - "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334", - "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5", - "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3", - "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe", - "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c", - "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed", - "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337", - "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880", - "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f", - "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d", - "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248", - "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d", - "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf", - "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512", - "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069", - "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb", - "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942", - "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d", - "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31", - "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92", - "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5", - "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28", - "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d", - "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1", - "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2", - "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875", - "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.8" - }, - "yamale": { - "hashes": [ - "sha256:1468f90f5019a82a77ff3f101bb001930765ef4b5e8d7fe658c1d2967e775f9a", - "sha256:9e9d6946d2f68926822d0df400dafb5e75b34bc7f482237393db29e697d5bbad" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.6'", - "version": "==3.0.8" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" - ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" - }, - "bleach": { - "hashes": [ - "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", - "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" - ], - "markers": "python_version >= '3.7'", - "version": "==6.0.0" - }, - "certifi": { - "hashes": [ - "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", - "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" - ], - "markers": "python_version >= '3.6'", - "version": "==2024.7.4" - }, - "cffi": { - "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.15.1" - }, - "charset-normalizer": { - "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" - }, - "commonmark": { - "hashes": [ - "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", - "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" - ], - "version": "==0.9.1" - }, - "coverage": { - "hashes": [ - "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", - "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", - "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", - "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", - "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", - "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", - "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", - "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", - "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", - "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", - "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", - "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", - "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", - "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", - "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", - "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", - "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", - "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", - "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", - "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", - "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", - "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", - "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", - "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", - "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", - "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", - "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", - "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", - "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", - "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", - "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", - "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" - ], - "index": "Brain Brew", - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", - "version": "==4.5.4" - }, - "cryptography": { - "hashes": [ - "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709", - "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069", - "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2", - "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b", - "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e", - "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70", - "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778", - "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22", - "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895", - "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf", - "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431", - "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f", - "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947", - "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74", - "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc", - "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66", - "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66", - "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf", - "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f", - "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5", - "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e", - "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f", - "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55", - "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1", - "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47", - "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5", - "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0" - ], - "markers": "python_version >= '3.7'", - "version": "==43.0.0" - }, - "docutils": { - "hashes": [ - "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", - "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" - ], - "markers": "python_version >= '3.7'", - "version": "==0.20.1" - }, - "idna": { - "hashes": [ - "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", - "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" - ], - "markers": "python_version >= '3.6'", - "version": "==3.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", - "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5" - ], - "markers": "python_version < '3.8'", - "version": "==6.7.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", - "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a" - ], - "markers": "python_version < '3.9'", - "version": "==5.12.0" - }, - "jaraco.classes": { - "hashes": [ - "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", - "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" - ], - "markers": "python_version >= '3.7'", - "version": "==3.2.3" - }, - "jeepney": { - "hashes": [ - "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", - "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" - ], - "markers": "sys_platform == 'linux'", - "version": "==0.8.0" - }, - "keyring": { - "hashes": [ - "sha256:3d44a48fa9a254f6c72879d7c88604831ebdaac6ecb0b214308b02953502c510", - "sha256:bc402c5e501053098bcbd149c4ddbf8e36c6809e572c2d098d4961e88d4c270d" - ], - "markers": "python_version >= '3.7'", - "version": "==24.1.1" - }, - "more-itertools": { - "hashes": [ - "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", - "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" - ], - "markers": "python_version >= '3.7'", - "version": "==9.1.0" - }, - "packaging": { - "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" - ], - "markers": "python_version >= '3.7'", - "version": "==24.0" - }, - "pkginfo": { - "hashes": [ - "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", - "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097" - ], - "markers": "python_version >= '3.6'", - "version": "==1.10.0" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.11.0" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "pytest": { - "hashes": [ - "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", - "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.5'", - "version": "==5.4.1" - }, - "readme-renderer": { - "hashes": [ - "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", - "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" - ], - "markers": "python_version >= '3.7'", - "version": "==37.3" - }, - "requests": { - "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" - ], - "markers": "python_version >= '3.7'", - "version": "==2.31.0" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, - "rfc3986": { - "hashes": [ - "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", - "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "rich": { - "hashes": [ - "sha256:3fba9dd15ebe048e2795a02ac19baee79dc12cc50b074ef70f2958cd651b59a9", - "sha256:ce5c714e984a2d185399e4e1dd1f8b2feacb7cecfc576f1522425643a36a57ea" - ], - "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", - "version": "==12.0.1" - }, - "secretstorage": { - "hashes": [ - "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", - "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" - ], - "markers": "sys_platform == 'linux'", - "version": "==3.3.3" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "twine": { - "hashes": [ - "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", - "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.7'", - "version": "==4.0.2" - }, - "typing-extensions": { - "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" - ], - "index": "Brain Brew", - "version": "==3.10.0.0" - }, - "urllib3": { - "hashes": [ - "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", - "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.7" - }, - "wcwidth": { - "hashes": [ - "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", - "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" - ], - "version": "==0.2.13" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "zipp": { - "hashes": [ - "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", - "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" - ], - "markers": "python_version >= '3.7'", - "version": "==3.15.0" - } - } -} diff --git a/README.md b/README.md index 16b7f70..1a89959 100644 --- a/README.md +++ b/README.md @@ -1,254 +1,116 @@ -# Brain-Brew +# Brain Brew - - +Brain Brew is a Rust-based, local-first deck federation tool for shared Anki-compatible decks. -Brain Brew is an open-source flashcard manipulation tool designed to allow users to convert their Anki flashcards to/from many different formats to suit their own needs. -The goal is to facilitate collaboration and maximize user choice, with a powerful tool that minimizes effort. -[CrowdAnki](https://github.com/Stvad/CrowdAnki) Exports and Csv(s) are the only supported file types as of now, but there will be more to come. +It continues the established Brain Brew project name while replacing the legacy Python recipe pipeline with canonical deck source, overlays, manifests, and reproducible verification. +It helps deck maintainers compose a base deck with translations, extensions, patches, and personal overlays while preserving stable identity through CrowdAnki export and full-deck bootstrap import. -[Anki Ultimate Geography](https://github.com/axelboc/anki-ultimate-geography/) is currently the best working example of a Flashcard repo using Brain Brew :tada: -See there for inspiration! +## Current Status +Brain Brew now has a working Rust core, reusable format codecs, and a thin CLI for Canonical Deck validation, overlay composition, CrowdAnki import/export, semantic diffing, media checks, authoring helpers, Federated Deck manifests, package-qualified target composition, and locked package inputs. -# Installation +The repository includes two tested fixtures: +- `fixtures/ug-style/` — a small Ultimate Geography-style fixture for fast end-to-end checks. +- `fixtures/ultimate-geography/` — a full Ultimate Geography canonical workspace used as a large parity case study, including Hardcore Geography as an extension overlay. -Install the latest version of [Brain Brew on PyPi.org](https://pypi.org/project/Brain-Brew/) -with `pip install brain-brew`. Virtual environment using `pipenv` is recommended! +Ultimate Geography is a fixture and case study for the general federation workflow; it is not a special product-specific CLI feature. -:exclamation: See the [Brain Brew Starter Project][BrainBrewStarter] for a working clone-able Git repo. -From this repo you can now create a functional Brain Brew setup automatically, -with your own flashcards! Simply by running +## Federated Deck workflow -```bash -brainbrew init [Your CrowdAnki Export Folder] -``` - -This will generate the entire working repo for you, including the recipe files, source files, and build folder. -For bi-directional sync: Anki <-> Source! - -See [the starter repo][BrainBrewStarter] for a step-by-step guide for all of this. +A Federated Deck workspace contains a base Canonical Deck, overlays, and a `brainbrew.yaml` manifest declaring reproducible build targets. -# Usage - -Brain Brew runs from the command line and takes a *Recipe.yaml* file to run. +Common commands: ```bash -brainbrew run source_to_anki.yaml +brainbrew targets --manifest brainbrew.yaml --json +brainbrew targets --package-root ../anki-geo-packages +brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography +brainbrew lock verify +brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/ +brainbrew explain --manifest brainbrew.yaml --target de-extended --json +brainbrew compose --manifest brainbrew.yaml --target de-extended --out build/de-extended.yaml +brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/ +brainbrew diff deck.yaml edited.yaml --as-overlay --id overlay.patch.capitals --kind patch ``` -Full usage help text: -```bash -Brain Brew vx.y.z -usage: brainbrew [-h] {run,init} ... - -Manage Flashcards by transforming them to various types. - -positional arguments: - {run,init} Commands that can be run - run Run a recipe file. This will convert some data to another format, based on the instructions in the recipe file. - init Initialise a Brain Brew repository, using a CrowdAnki export as the base data. - -optional arguments: - -h, --help show this help message and exit -``` - - -## Recipes - -These are the instructions for how Brain Brew will ~~build~~ *brew* your data into another format. - -What's YAML? See the current spec [here](http://www.yaml.org/spec/1.2/spec.html). - -Run a recipe with `--verify` or `-v` to confirm your recipe is valid, without actually running it. -A dry run of sorts. - -### Tasks - -A recipe is made of many individual tasks, which do specific functions. -Full detailed list coming soon™️, but see the [Yamale recipe schema](https://github.com/jeprecated/brain-brew/blob/master/brain_brew/schemas/recipe.yaml) -(local file: `brain_brew/schemas/recipe.yaml`) in the meantime :+1: - - - - -[//]: <> (Yamale) - -# The Why - -Brain Brew was made in an effort to solve some of the following issues with current collaboration of Anki Flashcards: - -#### Sharing Personal Information or Copyrighted Material - -Have some personal notes on your cards? Used some images randomly taken from the internet? -That usually means you cannot share your deck entirely, without having to go to the effort of removing the offending material and/or managing two separate copies. - -#### Having to Pick Between Source Control or Anki Editing - -Putting your cards into a source control system brings a lot of benefits. -You can see any changes that occur, go back in time should an mistake be discovered, and collaborate with others. - -However the current tools for managing Anki cards in source control -(such as [Anki-DM](https://github.com/OnkelTem/anki-dm), [GenAnki](https://github.com/kerrickstaley/genanki), -and [Remote Decks](https://github.com/c-okelly/anki-remote-decks)) are only one way. -You generate cards from a csv into a file that can *only be imported* into Anki. -There is no way to export them back, meaning a user must manually copy their changes over, or simple not edit their cards anywhere other than in source control. - -This robs the user of two important work flows: -1. Editing/fixing cards in Anki as you review them (on desktop or mobile) -1. The plethora of Anki add-ons that already exist that are amazingly useful. E.g: Image Occlusion, Morphman, AwesomeTTS. - -A user should not have to pick between these fantastic work flows and the usage of source control to structure, manage, and share their cards. +CrowdAnki import bootstraps a new full-deck workspace after plan/review/apply; it never merges Anki edits into an existing source or overlay stack. Preserve the source, compare deliberately, and treat `diff --as-overlay` output as a review artifact before manually routing accepted changes. See [CrowdAnki bootstrap boundary](documentation/docs/authoring/crowdanki-bootstrap-boundary.md). -#### Lack of Formatting Choice +See the dedicated documentation site in [`documentation/`](documentation/) for manifest, source variable, translation dictionary, overlay, locking, and example workflows. Lock update/verify uses Rust-native fetching and NAR hashing; Nix is only an optional install/build path. -Csvs are great for editing data, but can only go so far by themselves. Having all the data inside one csv leaves a lot to be desired and can result in eventual problems. -When one gets as many columns as *this* (from [Ultimate Geography](https://github.com/axelboc/anki-ultimate-geography/)) then it becomes a nightmare to manage: +## Install the CLI -|guid|Country|Country:de|Country:es|Country:fr|Country:nb|"Country info"|"Country info:de"|"Country info:es"|"Country info:fr"|"Country info:nb"|Capital|Capital:de|Capital:es|Capital:fr|Capital:nb|"Capital info"|"Capital info:de"|"Capital info:es"|"Capital info:fr"|"Capital info:nb"|"Capital hint"|"Capital hint:de"|"Capital hint:es"|"Capital hint:fr"|"Capital hint:nb"|Flag|"Flag similarity"|"Flag similarity:de"|"Flag similarity:es"|"Flag similarity:fr"|"Flag similarity:nb"|Map|tags| -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -|crr.AfnVRi|England|England|Inglaterra|Angleterre|England|"Constituent country of the United Kingdom."|"Landesteil des Vereinigten Königreichs."|"Nación constitutiva del Reino Unido."|"Nation constitutive du Royaume-Uni."|"Land som utgjør en del av Storbritannia."|London|London|Londres|Londres|London| | | | | |"Not a sovereign country"|"Kein souveräner Staat"|"No es un país soberano"|"Pas une nation souveraine"|"Ikke selvstendig land"|""| | | | | |""|UG::Europe| -"h"|"Ireland (orange and green flipped, wider)"|"Irland (Orange und Grün vertauscht, breiter)"|"Irlanda (naranja y verde intercambiados, más ancha)"|"Irlande (orange et vert inversés, plus large)"|"Ireland (byttet plass på oransje og grønt, bredere)"|""|"UG::Africa UG::Sovereign_State UG::West_Africa" +For normal deck users and contributors, install the released `brainbrew` CLI from crates.io or a GitHub Release. You do not need Nix to edit or verify a Federated Deck workspace, and downstream projects such as Ultimate Geography can link to the release version they recommend. -Then there's having too many rows in one csv for it to be properly managed. +After the alpha.2 release gates pass and the maintainer manually publishes it to crates.io, Rust users can install the current preview crate: - -# Features of Brain Brew -### Multi-directional Card Syncing -Make changes in your source file and sync those into your Anki collection. - -Make changes inside Anki and pull those back into the source. - -Any user of your shared deck can make a change inside Anki and at some later point export their deck (or just part of it) using CrowdAnki. -Then the source file can be updated with their changes and a new CrowdAnki Export for all users to import can be generated with one run of Brain Brew. - -### Modular Configuration Files -Yaml config files are what drive the conversion of Brain Brew, allowing users to easily change the functionality as they wish. - - - -```Yaml -- generate_guids_in_csv: - source: src/data/words.csv - columns: [ guid ] - -- build_parts: - - note_model_from_yaml_part: - part_id: LL Word - file: src/note_models/LL Word.yaml - - - headers_from_yaml_part: - part_id: default header - file: src/headers/default.yaml - override: # Optional - deck_description_html_file: src/headers/desc.html - - - media_group_from_folder: - part_id: all_media - source: src/media - recursive: true # Optional - - - notes_from_csvs: - part_id: english-to-danish - - note_model_mappings: - - note_models: - - LL Word - columns_to_fields: # Optional - guid: guid - tags: tags - - english: English - danish: Word - picture: Picture - danish audio: Pronunciation (Recording and/or IPA) - - file_mappings: - - file: src/data/words.csv - note_model: LL Word - sort_by_columns: [english] # Optional - reverse_sort: no # Optional +```bash +cargo install brainbrew --version 1.0.0-alpha.2 --locked +brainbrew --version ``` -### Personal Fields -Deck managers can set specific fields to be "Personal", meaning they will not overwrite an existing value on import. - -Working version currently exists, but full PR coming soon to CrowdAnki! - -### Extensibility and Open Source -Free for all to use, modify, or sell this product. +For a no-Rust GitHub Release install, download the versioned installer and its +release checksum before executing it; never pipe a downloaded installer into a +shell. See the [installation guide](documentation/docs/getting-started/install.md) +for the checksum-verified commands. -Further source types are relatively easy to add due to the flexible nature of the backend -Instead of creating a Csv <-> CrowdAnki converter Brain Brew first goes through a middle layer called "Deck Parts". -These consist of Notes, Headers, Note Models, and Media files. +To test the exact GitHub tag instead of the crates.io package, install directly from the pinned release tag: -Each new source type to be added to Brain Brew (such as Markdown) need only be able to convert from Deck Parts <-> itself, and suddenly it can convert to and from all existing source types! - -### Smart Csvs - -Csvs only update the rows which have changed. -Meaning a user can import *a subset* of their cards which have changed and still update the source file without deleting the cards they did not include. - -##### Csv Splitting / Derivatives - -Split data into multiple csvs so that your data is neatly organised however you like. - -The two following csv files contain information about England, but split into different csv files: +```bash +cargo install --git https://github.com/jeprecated/brain-brew --tag v1.0.0-alpha.2 brainbrew --locked +``` -###### data-main.csv +Nix remains available as an optional reproducible build/install path for contributors and CI. Pin the same release tag for a reproducible external Nix channel: -| guid | country | flag | map | tags | -| ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | +```bash +nix run . -- --help +nix build .#brainbrew +nix run github:jeprecated/brain-brew/v1.0.0-alpha.2 -- --help +``` -###### data-capital.csv -| country | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | -| England | London | London | Londres | Londres | London | +See [`documentation/docs/getting-started/install.md`](documentation/docs/getting-started/install.md) for all install options and an edit/export loop for trying changes against a Federated Deck workspace. -Brain Brew can be told that `data-capital` is a derivative of `data-main` in the build config file as such: +## Workspace -```yaml -- file: src/data/data-main.csv # <---- Main - note_model: Ultimate Geography - derivatives: - - file: src/data/data-country.csv - - file: src/data/data-country-info.csv - - file: src/data/data-capital.csv # <---- Capital - - file: src/data/data-capital-info.csv - - file: src/data/data-capital-hint.csv - # note_model: different_note_model - # derivatives: - # - file: derivative-of-a-derivative.csv - # derivatives: - # - file: infinite-nesting.csv - - file: src/data/data-flag-similarity.csv +```text +crates/ + brain-brew-core/ Pure domain model, validation, composition, semantic diffing + brain-brew-formats/ Reusable YAML and CrowdAnki codecs + brain-brew-cli/ Thin `brainbrew` command-line package ``` -When run Brain Brew will perform the following steps for each derivative: -1. Finds which columns in the derivative csv match the main (only `country` in this case) -1. Go through each row in the derivative and find the row with matching values in the main file -1. Add in the extra columns (`capital` in each language) to that matching row in the main file - -###### Resulting csv data -| guid | country | flag | map | tags | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | London | London | Londres | Londres | London | +## Development -##### Note: +This project uses Devenv. With direnv, `.envrc` loads the environment automatically; otherwise run commands through `devenv shell`: -1. **Derivatives can also have derivatives**. - -1. **Csv splitting works in both directions**, to and from csv. +```bash +devenv shell fmt +devenv shell test +devenv shell clippy +devenv shell ci +devenv test +``` -1. **Derivatives can be given a Note Model**, which overrides their parent's note model for all the matched rows. +Rust commands in Devenv default to two libtest threads and two Cargo build +jobs. This keeps CPU and thermal load lower at the cost of longer test/build +runtime. Existing `RUST_TEST_THREADS` and `CARGO_BUILD_JOBS` values are +preserved, so higher-throughput one-off runs can opt in explicitly: -See the [Brain Brew Starter Project][BrainBrewStarter] for an example of Csv Derivatives working. +```bash +RUST_TEST_THREADS=8 CARGO_BUILD_JOBS=8 devenv shell test +RUST_TEST_THREADS=1 CARGO_BUILD_JOBS=4 devenv shell -- cargo test -p brain-brew-core +devenv shell check:rust-parallelism +``` +These settings govern Rust test/compilation parallelism only; they do not limit +Chromium, Trunk, npm, Nix builds, or other non-Cargo tools. +Useful docs: -[BrainBrewStarter]: https://github.com/jeprecated/brain-brew-starter +- Agent guidance: [`AGENTS.md`](AGENTS.md) +- Documentation site source: [`documentation/`](documentation/) +- Start here: [`documentation/docs/intro.md`](documentation/docs/intro.md) +- Domain glossary: [`documentation/docs/reference/glossary.md`](documentation/docs/reference/glossary.md) +- Project scope: [`documentation/docs/reference/project-scope.md`](documentation/docs/reference/project-scope.md) +- Active ADRs: [`documentation/docs/reference/decisions/`](documentation/docs/reference/decisions/) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 1dbfcb7..0000000 --- a/TODO.md +++ /dev/null @@ -1,9 +0,0 @@ -# Todos - -- [ ] Better error messages - - No stack trace, unless they add -v -- [ ] Save note model to yaml - - Respect the current positions of the child files - - Be able to do it for multiple note models at a time, while checking their shared components are the same -- [ ] Save headers build task - - Remove the save_to_file diff --git a/audit/01-flow-map.md b/audit/01-flow-map.md new file mode 100644 index 0000000..b3e9657 --- /dev/null +++ b/audit/01-flow-map.md @@ -0,0 +1,258 @@ +# Code Context + +Scope: read-only review of the tracked Brain Brew workspace and `/home/jmo/Development/external/ultimate-geography`. No project or consumer source was changed. + +## Files Retrieved + +1. `Cargo.toml` (lines 1-27), `crates/*/Cargo.toml` (whole files) - workspace and dependencies. +2. `crates/brain-brew-cli/src/main.rs` (1-116), `args.rs` (1-363), `io.rs` (1-643) - dispatch, arguments, and the shared filesystem/package planner. +3. `crates/brain-brew-core/src/model.rs` (1-1402), `compose.rs` (1-2169), `translation.rs` (1-1870), `validate.rs` (1-459), `messages.rs` (1-342) - pure domain and behavior. +4. `crates/brain-brew-formats/src/canonical_yaml.rs` (1-2717), `manifest.rs` (1-851), `source_includes.rs` (1-743), `crowdanki.rs` (1-1309), `media.rs` (1-354), `lockfile.rs` (1-243) - codecs/adapters. +5. `crates/brain-brew-cli/src/commands/{import,compose,export,verify,media,lock,translations,workbench}.rs` (whole files) - requested command flows and workbench server. +6. `crates/brain-brew-workbench-ui/src/lib.rs` (1-4243), `staging.rs` (1-163), `main.rs` (1-8) - Leptos/WASM UI and local drafts. +7. `crates/*/tests/*.rs`, `crates/brain-brew-core/src/tests.rs` - unit, integration, fixture, API, and browser tests. +8. `devenv.nix` (1-114), `scripts/check_workbench_ui_embed.sh` (1-28), `scripts/sync-ug-fixture.sh` (1-85) - build/test/fixture pipelines. +9. `/home/jmo/Development/external/ultimate-geography/{brainbrew.yaml,brainbrew-hardcore.yaml,deck.yaml,deck-hardcore.yaml}` (1-605, 1-373, 1-7154, 1-79) - first consumer's graph and bases. +10. `/home/jmo/Development/external/ultimate-geography/overlays/languages/es.yaml` (1-1005), `overlays/extensions/hardcore.yaml` (1-1047), `overlays/extensions/hardcore/field-fills.yaml` (1-84), `overlays/variants/extended.yaml` (1-28) - representative translation/extension/variant forms. +11. `/home/jmo/Development/external/ultimate-geography/CONTRIBUTING.md` (1-527), `.github/workflows/integrity-check.yml` (1-46), `docs/pr736-equivalence-evidence.md` (1-87) - documented/CI use. +12. `fixtures/ultimate-geography/**`, `crates/brain-brew-formats/tests/ultimate_geography_fixture.rs` (1-1929) - migrated consumer-shaped fixture. + +## Key Code + +### Topology and interfaces + +```text +brain-brew-core (pure, no dependencies) + ^ +brain-brew-formats (strict YAML/JSON, CrowdAnki, manifests, locks, media) + ^ +brainbrew CLI (filesystem, terminal, HTTP/fetching, workbench server) + +brain-brew-workbench-ui (independent wasm-only Leptos/gloo/web-sys client) +brain-brew-workbench-e2e (independent thirtyfour WebDriver harness) +``` + +Workspace members are `Cargo.toml:1-8`; intended boundaries are documented in `crates/brain-brew-core/src/lib.rs:1-16` and `crates/brain-brew-formats/src/lib.rs:1-18`. + +Critical interfaces: + +- `StableId` and typed dotted `DeckPath`: `crates/brain-brew-core/src/model.rs:9-374`. +- `CanonicalDeck`, `Overlay`, `TranslationDictionary`, change intents/expected bases, notes/types/templates/messages/media: `model.rs:676-1186`. +- `CanonicalDeck::{compose,semantic_diff,render_variables}`: `compose.rs:10-73`. +- `CanonicalDeck::{translation_coverage,translation_context}`: `translation.rs:10-26`. +- `FederatedDeckManifest`, `BuildTarget`, language/profile/export metadata: `crates/brain-brew-formats/src/manifest.rs:12-19,161-270`. +- `ManifestTargetPlan`: composed CLI plan containing loaded base plus ordered `(PlannedOverlay, Overlay)` entries, `crates/brain-brew-cli/src/io.rs:143-180`. +- `CrowdAnkiExport` and strict import/export: `crates/brain-brew-formats/src/crowdanki.rs:13-88`. +- `FederationLock`/`LockedPackage`/`LockedSource`: `crates/brain-brew-formats/src/lockfile.rs:12-42`. + +### Composition semantics + +`CanonicalDeck::compose` clones the base, resolves structured messages, applies overlays in order, resolves messages after each overlay, accumulates errors, then validates (`core/src/compose.rs:10-43`). Application order is translation dictionary -> deck metadata -> note types/templates/fields -> notes -> added-field blank backfill -> media (`compose.rs:76-151`). Changed paths are attributed to overlays; a second non-override writer conflicts, and destructive intents require expected bases (`compose.rs:1594-1640`). + +Translation precedence is target adaptation -> ignored/empty -> variable-specific -> most-specific contextual -> direct -> no-change -> stale -> missing (`core/src/translation.rs:240-310`). Coverage extracts deck, note-type, note/message, tag, variable, and adapter text (`translation.rs:28-157`). Structured source messages retain translatable text and field references, while composition renders final scalar fields (`core/src/messages.rs:5-196`). + +### Source contract + +Canonical YAML parse validates immediately; overlay parsing is strict; emitters are deterministic (`formats/src/canonical_yaml.rs:23-182`). Reads resolve `!include` before parsing (`cli/src/io.rs:47-91`). Includes are restricted to scalar content positions plus top-level `media: !include`, and root escape needs an allowed `include_roots` path (`formats/src/source_includes.rs:13-78,321-450`). Formatting replaces directives with sentinels, formats the domain value, then restores directives (`source_includes.rs:56-181`). + +## Architecture + +### CLI entry and package/target spine + +`main` manually dispatches `fmt`, `validate`, `compose`, `export`, `import`, `lock`, `media`, `targets`, `translations`, `verify`, `workbench`, `explain`, and `diff`; it aliases `translate/translation` and limits JSON error envelopes to machine-facing commands (`cli/src/main.rs:15-90`). Parsing is handwritten in `args.rs:1-363`. + +Most target commands share this flow: + +```text +manifest + -> ManifestRegistry::load + (root + --include + recursive --package-root + sibling brainbrew.lock manifests) + -> optional package dependency/version validation + -> recursive package-qualified target `extends` + -> dependency-expand/de-duplicate/cycle-check overlay refs + -> read base/overlays with each package's include roots + -> ManifestTargetPlan + -> CanonicalDeck::compose +``` + +See `cli/src/io.rs:151-180,196-435`. A second local-only expansion engine lives in `formats/src/manifest.rs:22-151`. + +### Compose + +- Ad hoc: read deck/overlays -> warn on stale translations -> core compose -> canonical YAML (`commands/compose.rs:52-87`). +- Manifest: parse target -> package-aware plan -> warning -> compose -> canonical YAML (`compose.rs:18-50`). + +Composition validates but does not variable-render, so canonical output may retain `${...}`. + +### Import + +```text +folder/deck.json + -> strict serde (`deny_unknown_fields`) + -> reject unsupported children/scheduling/config/model/field/template/note data + -> derive slugged suggested StableIds + -> preserve CrowdAnki UUID/GUID adapter IDs + -> fail on ID/UUID collisions + -> media_files become references with empty sha256 + -> strict whole-field sequences become structured images + -> core validate -> canonical YAML +``` + +CLI requires `--accept-suggested-ids` (`commands/import.rs:8-37`). Conversion is `formats/src/crowdanki.rs:788-925,936-1218`. It imports no media bytes and exposes no review/override mapping; collision text references a not-yet-public override path (`crowdanki.rs:1212-1214`). Empty imported hashes (`crowdanki.rs:885-910`) require later `media hash` before asset verification. + +### Export + +Both forms compose then call `write_crowdanki_export` (`commands/export.rs:17-94`). The adapter validates, variable-renders/lower messages and images, validates again, preserves configured adapter identities, supplies fixed defaults, omits tombstoned notes, and deterministically emits `deck.json` (`formats/src/crowdanki.rs:13-82,571-764`). With `--media-root`, CLI validates references/assets/hashes and copies each declared asset to `/media` (`commands/export.rs:86-106`; `media_assets.rs:23-75`). Without it, reference validation is skipped; `verify` is the stronger release gate. + +### Verify + +`verify --target/--all-targets` performs (`commands/verify.rs:18-90`): + +1. byte-canonical root manifest; +2. byte-canonical root base and its top-level included media map; +3. package-aware planning and byte-canonical expanded overlays; +4. incremental translation coverage (strict rejects fallback/stale; lenient warns stale); +5. composition + core validation; +6. media references only, or existence/hash with `--media-root`; +7. variable rendering and HTML/CSS validation unless skipped; +8. optional CrowdAnki golden comparison with explicit path-glob allowlist (`verify.rs:94-350`). + +CrowdAnki parity compares known arrays/media semantically and reports exact JSON paths (`formats/src/crowdanki.rs:92-568`). + +### Media + +Formats scans raw `[sound:]`, `src=`, `href=`, CSS `url()`, and structured image IDs. Used/undeclared and unknown IDs error; unused declarations warn; hashes validate against supplied bytes (`formats/src/media.rs:14-203`). + +- `media hash`: gathers root base plus expanded overlay source files, follows a hoisted media map, hashes under `--media-root`, and canonically rewrites changed sources (`commands/media.rs:34-87,262-363`). +- `media images-to-refs`: builds unique path->media-ID lookup and converts only strict whole-field image sequences; mixed/ambiguous/unmatched fields are counted and retained (`media.rs:89-136,365-628`). +- Export copies declared assets; verify catches missing/empty/mismatched assets (`cli/src/media_assets.rs:8-75`). + +### Lock/package federation + +`lock update` accepts one path, GitHub git URL, or tarball; snapshots a filtered tree, computes a Nix Archive Representation SHA-256, caches by hash, validates manifest package id/version, and emits deterministic lock YAML (`commands/lock.rs:25-91,321-575,714-770,924-970`). `lock verify` re-hashes (including live path drift) and checks locked metadata (`lock.rs:93-120,403-500,744-770`). Git mode is GitHub-HTTPS/API/codeload-specific; other remotes need tarballs (`lock.rs:509-548,675-735`). + +Normal planning silently discovers sibling `brainbrew.lock`, resolves cached/fetched package manifests, checks hash/metadata, and registers them (`io.rs:208-240`; `lock.rs:457-500`). Dependencies use exact optional `id@version`, not semver (`package_resolver.rs:9-61,89-93`). + +### Translations and auxiliary commands + +`translations` plans targets, composes incrementally to each translation overlay, asks core for coverage/context, filters/reports it, and optionally rewrites stubs or resolves stale records (`commands/translations.rs:23-121,975-1168,1251-1290,2682-2920`). Report/workbench composition clones and sanitizes stale entries and disables complete coverage (`commands/translation_overlay.rs:3-65`); release composition does not. + +`fmt` tries deck, overlay, manifest, lock, and media-map codecs (`io.rs:14-42`); `validate` composes then core-validates (`commands/validate.rs:14-184`); `diff` uses semantic stable paths and can draft a documented subset of overlay changes (`commands/diff.rs:10-35`; `overlay_draft.rs:11-42`); `targets` and `explain` expose package/stack/diff/conflict plans (`commands/targets.rs:9-78`; `commands/explain.rs:7-119`). + +### Workbench server -> UI -> source + +`workbench serve` reads one manifest, binds loopback, optionally opens a browser, and serves embedded Trunk assets or `--dev-assets` (`commands/workbench.rs:41-231,405-449`). Routes at `workbench.rs:191-221` serve workspace, paginated list/detail/pivot data, comparison, metadata, new-language, apply preview/apply, and declared media. + +Selection is language catalog -> target/overlay labels -> package plan -> incremental composition to the selected translation overlay -> source deck + coverage -> lenient target composition -> JSON context (`workbench.rs:1162-1360`). Selected contexts are cached by selection (`workbench.rs:491-599`). + +The real frontend is Leptos CSR. `App` owns selection/list/detail/error generations and staging (`workbench-ui/src/lib.rs:24-298`); Notes load first, Cards/Source Strings/Metadata lazily (`lib.rs:2510-3030`); generation tokens discard late responses (`lib.rs:3160-3241`). Draft JSON lives in localStorage keyed by language+target+overlay+kind+path+source (`staging.rs:33-146`). + +Apply Preview/Confirm gather every active storage prefix and post identical edit requests to preview/write endpoints; successful write clears drafts (`ui/src/lib.rs:1629-1694,3873-3969`). Server serializes applies, changes source first, recalculates context, groups translation edits by overlay, validates, previews affected files, then atomically temp-writes/renames and invalidates cache (`workbench.rs:878-1064,4050-4288,4682-4937`). Source edits create stale records or migrate keys (`workbench.rs:4099-4375`); translation edits mutate direct/contextual/no-change maps (`workbench.rs:4682-4775`). New-language creation atomically writes manifest and overlays (`workbench.rs:815-877,1837-2117`). + +### Tests + +The executed non-browser suite passed **404 tests**: + +| Layer | Entry points | Coverage | +|---|---|---| +| Core | `core/src/tests.rs:1-408`; `core/tests/*.rs` | paths/invariants, full intent/conflict matrix, messages, translations, render, semantic diff | +| Formats | `formats/tests/*.rs` | strict/adversarial deterministic YAML, overlays/manifests/locks/media/includes, CrowdAnki fail-closed cases | +| Full UG fixture | `formats/tests/ultimate_geography_fixture.rs:16-1929` | 74+26 targets, canonical source, structured messages/media, exact output propagation, optional release oracle | +| CLI/API | `cli/tests/cli.rs:13-7038` and companion files | subprocess/files/server, contracts, all commands, locks, workbench API/atomic writes | +| UI native | `workbench-ui/tests/workspace_summary.rs:1-17` | JSON summary only; real UI is wasm-gated | +| Browser | `workbench-e2e/tests/workbench_smoke.rs:1-3061` | 26 WebDriver workflows over real server/UI/localStorage/files | + +`devenv.nix:16-114` defines test/check/clippy, Trunk build/embed, browser E2E, and CI. Embedded release assets are regenerated and byte-diffed by `scripts/check_workbench_ui_embed.sh:1-28`. + +### Ultimate Geography consumer + +The live checkout has: + +- `brainbrew.yaml`: `anki-geo.ultimate-geography`, English `deck.yaml`, 92 overlay files, **74** standard/extended/experimental/standalone-Hardcore targets (`brainbrew.yaml:1-605`). +- `brainbrew-hardcore.yaml`: `anki-geo.hardcore-geography`, minimal shell, shared Hardcore overlays, **26** companion targets (`brainbrew-hardcore.yaml:1-373`). + +`deck.yaml:1-79` preserves CrowdAnki identity, variables, external descriptions/templates/CSS; 319 notes and 546 inline empty-hash media declarations follow (`deck.yaml:80-7154`). Spanish demonstrates dictionaries, adapter-ID maps, and guarded metadata replacement (`overlays/languages/es.yaml:1-1005`). Hardcore shares extension notes and blank-only field fills (`hardcore.yaml:1-1047`; `field-fills.yaml:1-84`). Extended adds shared templates (`variants/extended.yaml:1-28`). + +Documented use is targets -> verify both manifests/media -> compose inspection -> export (`CONTRIBUTING.md:47-102`). CI also runs a Python source checker then verifies/exports every target (`.github/workflows/integrity-check.yml:25-46`). Historical evidence records representative old-Python/new-Rust GUID/card/note parity (`docs/pr736-equivalence-evidence.md:1-87`). + +The Brain Brew fixture is a migrated derivative, not a byte snapshot: sync appends temporary language/profile catalogs (`scripts/sync-ug-fixture.sh:62-66`; fixture `brainbrew.yaml:606-807`) and tests expect hoisted, hashed media (`ultimate_geography_fixture.rs:258-328`). Current-binary diagnostic verify of live `en-standard` stopped because `deck.yaml` is noncanonical; its 546 empty hashes would then fail media-root validation. Fixture success therefore proves the intended migrated shape, not current live-checkout acceptance. + +## Surprising Coupling, Unmapped, and Dead/Transitional Areas + +1. **Workbench monolith/YAML coupling:** almost 5k server lines include DTOs, rendering, raw YAML surgery, translation policy, and atomic writes. Scalar-include writes bypass the reusable resolver and use `root.join` plus literal `..` rejection (`workbench.rs:4420-4519`), so readable safe external include roots are not equivalently editable. +2. **Incomplete cache watch:** signatures cover manifest/base/catalogued overlays only (`workbench.rs:573-598`), not included HTML/CSS/media maps or locked package sources. Materialized previews can remain stale. +3. **Consumer layout in product code:** media lookup walks ancestors for `external//media` (`workbench.rs:1094-1124`), explicitly matching this developer checkout; API coverage names it (`cli/tests/cli.rs:737-764`). +4. **Workbench package options narrower than CLI:** planning is always passed empty explicit include/package-root lists (`workbench.rs:1230-1238`), and serve exposes no such flags (`workbench.rs:71-154`); implicit locks still work. +5. **Transitional API:** ADR-015 specifies `card/source-string/metadata-detail` (`decisions/0015...md:26-47`), but only `note-detail` exists; UI still uses pivots/metadata (`workbench.rs:191-219`; UI `lib.rs:4038-4196`). +6. **Stale frontend docs:** accepted ADR-011/014 and UG guide say Iced/WASM (`0011...md:1-49`; `0014...md:7-17`; UG `CONTRIBUTING.md:185-199`), while code/Cargo use Leptos (`workbench-ui/Cargo.toml:17-25`; `src/lib.rs:108-121`). Current workbench reference is correct (`documentation/docs/reference/workbench.md:10-31`). +7. **Root-biased canonical verification:** only root manifest/base/media include and expanded overlays are byte-checked (`verify.rs:23-60`). Package manifests/bases reached through `extends`, and overlay media maps, are parsed but not all independently byte-checked. +8. **Export weaker without media root:** it skips reference validation unless `--media-root` exists (`export.rs:86-100`), allowing HTML references absent from emitted `media_files`; release callers need verify first. +9. **Import UX incomplete:** generated IDs, empty hashes, no media-byte copy, and no accepted-ID override interface (`import.rs:8-37`; `crowdanki.rs:808-925,1212-1214`) make import a bootstrap, not a complete guided round trip. +10. **Duplicated manifest expansion:** reusable local expansion (`manifest.rs:22-151`) and CLI package expansion (`io.rs:196-435`) can drift. +11. **Fixture sync gap:** sync appends consumer-missing language metadata but does not copy/create current fixture `media.yaml` (`sync-ug-fixture.sh:49-66`), while tests require it (`ultimate_geography_fixture.rs:258-328`). Running it against this checkout likely needs an unlisted migration step. +12. **UG redundant media copies:** CLI already copies exact declarations (`export.rs:99-101`), but UG docs/CI copy every media file again (`CONTRIBUTING.md:63-90`; workflow `35-45`). +13. **UG mid-adoption:** live manifests lack the workbench-required language catalog; the fixture supplies a temporary delta (`sync-ug-fixture.sh:62-66`; selection failure `workbench.rs:1287-1318`). The live checkout is not yet proof all current flows pass. +14. **Optional parity can skip:** release oracle absence prints a skip and passes (`ultimate_geography_fixture.rs:536-548,1922-1929`). +15. **Native UI tests miss real UI:** most UI code is wasm-gated; normal tests exercise only summary extraction. Trunk/embed checks and browser E2E are essential. + +## Start Here + +Open `crates/brain-brew-cli/src/io.rs:151-435`: it turns manifests, package roots, and locks into the ordered plan consumed by compose/export/verify/translations/workbench. Then read `crates/brain-brew-core/src/compose.rs:10-151,1594-1640`, followed by the relevant command adapter. + +## Acceptance Evidence + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Created only the assigned audit/01-flow-map.md artifact and reviewed CLI, core/formats, workbench server/UI, requested flows, tests, and Ultimate Geography without changing source." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Report provides exact file/line references, end-to-end data flow, test evidence, downstream observations, coupling findings, and residual risks for independent review." + } + ], + "changedFiles": ["audit/01-flow-map.md"], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "cargo test --workspace --exclude brain-brew-workbench-e2e --all-targets", + "result": "passed", + "summary": "404 passed, 0 failed across core, formats, CLI/API, fixtures, and non-wasm UI." + }, + { + "command": "target/debug/brainbrew targets --manifest /home/jmo/Development/external/ultimate-geography/brainbrew.yaml | wc -l; repeat for brainbrew-hardcore.yaml", + "result": "passed", + "summary": "Found 74 main and 26 Hardcore targets." + }, + { + "command": "target/debug/brainbrew verify --manifest /home/jmo/Development/external/ultimate-geography/brainbrew.yaml --target en-standard --media-root /home/jmo/Development/external/ultimate-geography/media", + "result": "failed (diagnostic)", + "summary": "Live consumer deck.yaml is not in canonical format; recorded as downstream drift, not a project test failure." + }, + { + "command": "find/rg/read/nl/wc repository inspection; fixture/consumer diff; jj status and jj diff --summary", + "result": "passed", + "summary": "Mapped entry points and ranges, quantified surfaces, confirmed consumer-fixture divergence, and checked working-copy state." + } + ], + "validationOutput": [ + "Non-browser workspace: 404 passed, 0 failed.", + "Ultimate Geography targets: 74 main, 26 Hardcore.", + "Diagnostic consumer verify: exit 1, deck.yaml is not in canonical format.", + "This subagent changed only audit/01-flow-map.md; concurrent audit artifacts from other workers are also visible in the shared working copy." + ], + "residualRisks": [ + "Browser E2E was not run; it needs Chromium/chromedriver and devenv e2e.", + "Embedded assets were inspected but not rebuilt/diff-validated.", + "Optional UG v5.3 oracle parity may have skipped when its cache was absent.", + "Live Ultimate Geography is mid-migration and differs from the tested fixture.", + "Review gate remains required by the parent/reviewer." + ], + "noStagedFiles": true, + "notes": "Jujutsu has no staging area. Shared status contains this artifact plus concurrent audit files owned by other workers; no project or consumer source file is modified by this task." +} +``` diff --git a/audit/02-core-correctness.md b/audit/02-core-correctness.md new file mode 100644 index 0000000..249d408 --- /dev/null +++ b/audit/02-core-correctness.md @@ -0,0 +1,147 @@ +## Review + +### Scope and governing decisions + +Reviewed `brain-brew-core` against `CONTEXT.md`, project scope, ADR-003/004/006/007/008/013/016, the extension skill, core implementation, and core tests. The requested `plan.md` and `progress.md` paths do not exist in this checkout, so they provided no additional requirements. + +### Correct + +- Stable paths and collection identity are deterministic, and validation catches map/payload ID mismatches plus duplicate field/template IDs (`crates/brain-brew-core/src/validate.rs:12-90`, `:98-123`, `:126-212`). +- Scalar destructive changes check concrete expected values, field-level raw `add`/`merge` rejects non-blank values, and same-path overlay conflicts require an explicit later `override` (`crates/brain-brew-core/src/compose.rs:590-650`, `:1113-1172`, `:1594-1616`). +- Full note and note-type bodies are rejected for non-`add` changes (`crates/brain-brew-core/src/compose.rs:296-317`, `:961-981`), matching ADR-007. +- Core tests are green: 79 unit/integration tests and 0 doc tests passed. + +### Ranked findings + +#### 1. High — Entity-level destructive expected bases are presence-only and often never compared + +**Evidence:** `has_expected_base` only checks `Option::is_some()` (`crates/brain-brew-core/src/compose.rs:1619-1633`). `CardTemplateChange` calls it (`:406-411`) and then replaces the complete template (`:489-516`) without comparing either `ExpectedBase::Value` or a fingerprint to the old template. `FieldDefinitionChange` does the same (`:838-847`, `:874-899`). Outer note/note-type replace/override expected bases are also only checked for presence (`:314-327`, `:978-990`). Media replacement does compare `Value`, but accepts `EntityPresent` as sufficient for replacement (`:1223-1255`, `:1269-1293`). This contradicts ADR-007’s requirement that replace/override carry the prior value or fingerprint and fail stale (`documentation/docs/reference/decisions/0007-require-explicit-conflict-and-destructive-change-semantics.md:13-24,41-43`). + +**Reproduction/reasoning:** Change a base card template upstream, then apply a complete `CardTemplateChange { intent: Replace, expected_base: Some(ExpectedBase::Value("definitely-wrong")), template: Some(...) }`. Composition succeeds because only `Some` is tested. The same applies to field definitions. A media replace with `EntityPresent` silently discards any upstream path/hash edit. + +**Smallest fix direction:** Define deterministic entity summaries/fingerprints and compare them for complete entity replace/override. Restrict `EntityPresent` to operations whose contract intentionally checks existence (at most remove), or remove complete replacement in favor of sparse property changes with concrete expected bases. + +**Missing tests:** Wrong `ExpectedBase::Value` must fail for card templates and field definitions; stale media changed after an `EntityPresent` baseline must fail replacement; outer note/note-type expected bases must either be evaluated or rejected as unsupported. + +#### 2. High — Structured-image fields are treated as blank, so `add`/`merge` can erase them without an expected base + +**Evidence:** Structured images use an empty placeholder in `Note.fields`. Field conflict and expected-base logic reads only that raw string (`crates/brain-brew-core/src/compose.rs:1124-1153`). Therefore `Add` and `Merge` see an existing structured image as blank (`:1155-1172`) and then replace it while deleting `field_images` (`:1178-1205`). `ExpectedBase::Value("")` likewise matches every structured-image payload, regardless of referenced media IDs. ADR-007 says field fills may only fill values that are *still blank*, while changing existing content requires replace with its expected base; ADR-016 says the three field representations are exclusive semantic values (`documentation/docs/reference/decisions/0007-require-explicit-conflict-and-destructive-change-semantics.md:15`; `documentation/docs/reference/decisions/0016-use-structured-image-field-references-and-severable-media-includes.md:70-80`). + +**Reproduction/reasoning:** Start with `fields[field.flag] = ""` and `field_images[field.flag] = [media.old]`. Apply a field `Merge` or `Add` with raw value `"replacement"` and no expected base. Composition succeeds and removes `media.old`; the field was semantically non-blank. + +**Smallest fix direction:** Centralize a semantic field-value accessor/enum used by blank checks and expected-base checks. A field is blank only when it has no structured message/images and its raw value is empty. Extend expected bases to compare structured values or fingerprints. + +**Missing tests:** `add`, `merge`, and field-fill over existing structured images must fail; replace with an empty scalar expected base must not match a structured image; equivalent cases should cover structured messages that render empty. + +#### 3. High — Flat, untyped tombstones alias unrelated entity kinds and removals are recorded inconsistently + +**Evidence:** Tombstones are `BTreeSet` with no entity kind/path (`crates/brain-brew-core/src/model.rs:674-685`). Removing a note type inserts only its bare ID (`crates/brain-brew-core/src/compose.rs:251-293`), while validation treats a note with that same bare ID as tombstoned and skips all live-note invariants (`crates/brain-brew-core/src/validate.rs:203-227`). Cross-kind stable-ID collisions are not checked. Conversely, card-template, field-definition, and media removals physically delete entities without adding tombstones (`crates/brain-brew-core/src/compose.rs:472-487`, `:862-872`, `:1258-1268`), despite ADR-007’s statement that removals are represented as tombstones. `apply_note_add` can also clear any same-ID tombstone, regardless of what kind was removed (`:923-958`). + +**Reproduction/reasoning:** Give a live note ID `entity.shared` and an unrelated unused note type ID `entity.shared`. Remove the note type. The result’s tombstone now marks the live note as removed too, and `validate()` skips its invariants. In a later composition, adding a note with the same ID clears the note-type removal record. + +**Smallest fix direction:** Make tombstones typed/path-addressed (for example, note/note-type/media/template/field tombstone variants) and make all entity removals emit the appropriate type. If compatibility requires flat tombstones temporarily, enforce global cross-kind StableId uniqueness and reject ambiguous tombstones, but that alone does not fix missing removal records. + +**Missing tests:** Same ID across note and note type must not cross-tombstone; note add must not clear another kind’s tombstone; media/template/field removals must preserve deliberate-removal history; validation must reject orphan/ambiguous tombstones. + +#### 4. High — Structured-message references are resolved in one snapshot pass, producing stale output and accepting cycles + +**Evidence:** Resolution clones one snapshot, renders every message against that unchanged snapshot, and only writes results afterward (`crates/brain-brew-core/src/messages.rs:133-160`). Field references read the cached scalar from `Note.fields`, not the referenced structured message (`:182-190`, `:300-307`). Validation checks only that the referenced raw field key exists (`:95-113`); it does not detect dependency cycles. Composition invokes this one-pass resolver (`crates/brain-brew-core/src/compose.rs:10-26`). + +**Reproduction/reasoning:** Let field B be a structured message whose cached raw value is blank and whose component is `Text("resolved")`; let field A be a structured message referencing B. One pass resolves B to `resolved` but resolves A from the snapshot’s blank B, so the returned deck contains the wrong A value. A↔B cycles also validate and resolve to whatever stale cached strings happened to be present. + +**Smallest fix direction:** Build a structured-message dependency graph, evaluate acyclic references in topological order, and emit a path-rich cycle error. Do not use mutable cached raw field text as the authority for another structured message. + +**Missing tests:** Multi-hop references, order independence, direct self-reference, multi-field cycles, and cycles spanning notes. + +#### 5. High — Semantic diff can report materially different canonical decks as identical + +**Evidence:** Top-level diff compares name, description, variables, note types, notes, media, and tombstones, but omits `CanonicalDeck.id` and deck-level `adapter_ids` (`crates/brain-brew-core/src/compose.rs:45-67`). Note diff compares only raw `fields`, not `field_messages` or `field_images` (`:1972-2013`, especially `:2001`; `:2017-2052`). Thus changing a structured image media ID while retaining the required blank placeholder yields an empty diff; changing structured-message source while leaving cached text unchanged does too. Current semantic-diff tests cover only one raw field, add/remove notes, and tombstones (`crates/brain-brew-core/tests/semantic_diff.rs:8-61`). This conflicts with ADR-003/004’s canonical-entity and stable-identity semantics and with the documented claim that semantic regression checks cover deck structure rather than source formatting. + +**Reproduction/reasoning:** Clone a deck and change only `deck.id`, deck `adapter_ids`, `field_images[field.flag][0].media_id`, or a `MessageComponent::Text` while leaving `Note.fields` unchanged. `semantic_diff().is_empty()` remains true for each case. + +**Smallest fix direction:** Add stable paths for deck identity and diff every canonical field, including deck adapter IDs and structured representations. Prefer per-entity/per-property changes over opaque collection summaries so before/after values remain actionable. + +**Missing tests:** Deck ID, deck adapter ID, structured image, structured message component/format/reference, and representation-change diffs; assert no false equality between different canonical values. + +#### 6. Medium — `validate()` and `compose()` accept unknown structured media IDs even though the ADR requires a hard compose/render error + +**Evidence:** Validation iterates image IDs and checks StableId/path shape and emptiness but never checks `self.media.contains_key(image.media_id)` (`crates/brain-brew-core/src/validate.rs:178-200`, `:271-297`). `UnknownMediaReference` exists but is emitted only during `render_variables()` (`crates/brain-brew-core/src/compose.rs:1776-1795`; `crates/brain-brew-core/src/model.rs:1389-1402`). Since compose’s final gate is `validate()`, a resolved deck with an unknown image ID is returned successfully. ADR-016 requires an unknown ID to fail closed with a hard render/compose error (`documentation/docs/reference/decisions/0016-use-structured-image-field-references-and-severable-media-includes.md:76-80,100-107`). + +**Reproduction/reasoning:** Replace a valid raw field with `images: [media.missing]` using a matching scalar expected base. `compose()` succeeds; only a later render/verify discovers the invalid reference. + +**Smallest fix direction:** Add the media existence check to core validation so compose’s final validation fails with the existing `UnknownMediaReference` path and kind. Keep render-time checking as defense in depth. + +**Missing tests:** Base validation and overlay composition with unknown image IDs, including one missing member in a multi-image field. + +#### 7. Medium — `FieldChange` admits multiple conflicting payloads and silently chooses one + +**Evidence:** The public core model independently permits `value`, `message`, and `images` (`crates/brain-brew-core/src/model.rs:1077-1085`). Application chooses `message`, else `images`, else `value` (`crates/brain-brew-core/src/compose.rs:1178-1205`) without rejecting multiple payloads. This makes invalid states representable and can silently discard caller intent. The YAML conversion also constructs these options independently, so this is not only a hypothetical internal misuse. + +**Reproduction/reasoning:** Construct a `FieldChange` with both `value: Some("raw")` and `message: Some(...)`; composition silently uses the message and drops `raw`. Supplying all three behaves the same. + +**Smallest fix direction:** Validate payload cardinality before mutation: exactly one of value/message/images for add/merge/replace/override, and none for remove; reject empty image vectors at the change boundary. + +**Missing tests:** Every conflicting pair, all three payloads, remove-with-payload, and zero-payload non-remove. + +#### 8. Note — Compose errors flatten validation categories + +Final validation errors are converted to `ComposeErrorKind::ValidationFailed`, retaining only path/message (`crates/brain-brew-core/src/compose.rs:27-39`). Machine consumers cannot distinguish missing note types, invalid StableIds, conflicting field representations, or unknown media without parsing English. Preserve the originating `ValidationErrorKind` as structured data or map to dedicated compose kinds. Existing human-readable paths/messages are otherwise useful. + +### Review gate + +- **Blocker:** The high-severity expected-base, structured-field, tombstone, message-resolution, and semantic-diff issues prevent treating the current core as fail-closed for federation/round-trip correctness. +- **Note:** No source fix was applied because this task was explicitly review-only. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only scope was preserved; the only created project artifact is audit/02-core-correctness.md. No project/source files or tests were modified." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "The audit contains ranked findings with exact file:line evidence, concrete reproductions/reasoning, smallest fix directions, and missing tests, grounded in governing ADRs." + } + ], + "changedFiles": [ + "audit/02-core-correctness.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Confirmed the working copy was initially clean." + }, + { + "command": "devenv shell cargo test -p brain-brew-core", + "result": "passed", + "summary": "All 79 brain-brew-core unit/integration tests passed; 0 failed; doc tests passed." + }, + { + "command": "jj status && jj diff --summary (post-audit)", + "result": "passed", + "summary": "Post-write status was checked; only the requested audit artifact is expected, with no source changes." + } + ], + "validationOutput": [ + "brain-brew-core: 6 unit tests passed", + "canonical_deck_validation: 9 passed", + "content_validation: 3 passed", + "overlay_compose: 55 passed", + "semantic_diff: 4 passed", + "translation_coverage: 2 passed", + "doc-tests: 0 failed" + ], + "residualRisks": [ + "No adversarial tests were added because the assignment prohibited source/test modification; reproductions are reasoned from exact control flow.", + "plan.md and progress.md were absent, so no plan-specific constraints could be reviewed.", + "Passing current tests does not cover the missing edge cases enumerated in the findings." + ], + "noStagedFiles": true, + "notes": "Jujutsu has no staging area. The review gate is not clear: high-severity correctness gaps remain." +} +``` diff --git a/audit/03-overlays-translations.md b/audit/03-overlays-translations.md new file mode 100644 index 0000000..ae6fcf5 --- /dev/null +++ b/audit/03-overlays-translations.md @@ -0,0 +1,166 @@ +# Federated Deck overlays, translations, variables, and field fills audit + +## Review + +- **Correct:** The migrated UG workspace follows the shared-extension pattern for card structure: `overlays/variants/extended.yaml:1-27` adds the two templates once, while language residues such as `overlays/variants/extended/da.yaml:1-11` only preserve adapter identity. The base templates use source variables rather than copied localized HTML (`deck.yaml:11-20`, `templates/ultimate-geography/country-flag/answer.html:6-8`). +- **Correct:** `field_fills` lowers to checked replacements with an empty expected base (`crates/brain-brew-formats/src/canonical_yaml.rs:1611-1661`), and the core rejects a fill once upstream is nonblank (`crates/brain-brew-core/src/compose.rs:997-1056`). UG uses that shorthand appropriately for extension-owned blank content (`overlays/extensions/hardcore/field-fills.yaml:1-29`). +- **Correct:** Direct/contextual/no-change keys are stale-checked, contextual matching is boundary-aware, and stale records remain explicit review debt (`crates/brain-brew-core/src/translation.rs:240-309`, `1268-1366`, `1748-1752`, `1804-1821`). Core/formats tests passed, including overlay compose, translation coverage, field-fill shorthand, and UG fixture suites. +- **Note:** The requested `plan.md` and `progress.md` do not exist at the project root; this review used the skill, docs, implementation, tests, checked fixtures, and real UG workspace directly. + +## Ranked findings + +### 1. Blocker — translating a field definition can silently break every card template that references it + +**Evidence.** Translation extraction explicitly treats field-definition names as translatable (`crates/brain-brew-core/src/translation.rs:73-82`), and composition mutates those names (`crates/brain-brew-core/src/translation.rs:1167-1176`). It translates template names and template variables, but never rewrites Anki `{{Field name}}` references in `question_format` or `answer_format` (`crates/brain-brew-core/src/translation.rs:1178-1203`). Content validation only parses templates as HTML fragments and does not validate Mustache references against the final field names (`crates/brain-brew-core/src/content_validation.rs:93-125`). This conflicts with the documented translation-profile concept, which explicitly presents field labels as translator-reviewable metadata (`documentation/docs/authoring/manifests-targets.md:83-89`). + +A minimal export using the checked `fixtures/ug-style/deck.yaml` and `translations.direct: { Capital: Hauptstadt }` succeeded, but emitted a field named `Hauptstadt` while retaining `{{Capital}}` in two templates. There was no diagnostic. + +**Consumer impact.** A translator following the exposed coverage report can produce an export whose cards are blank or invalid in Anki. `verify` and `export` both accept it. UG currently avoids the failure by leaving field names untranslated and translating visible labels through variables, but strict coverage then treats those field names as missing work. + +**Required direction.** Either field-definition names must be structural/non-translatable by default, or translation must atomically rewrite and validate all Anki field references (including conditionals, type answers, and any other supported syntax). Add a regression test that translates `Capital` and proves exported template references remain valid. + +### 2. High — UG's localized Hardcore targets apply content translation before adding the content, leaving the extension and fills in English + +**Evidence.** A main-workspace Hardcore translation depends on the base language translation and then the extension (`brainbrew.yaml:55-60`). Dependency expansion therefore produces base translation → `overlay.extension.hardcore` → the adapter-ID-only Hardcore translation (`overlays/extensions/hardcore/translations/cs.yaml:1-12`). The target then applies English `field_fills` afterward (`brainbrew.yaml:367-370`; values at `overlays/extensions/hardcore/field-fills.yaml:22-29`). Core composition applies overlays strictly in expanded order (`crates/brain-brew-core/src/compose.rs:76-115`). + +The translations that should localize those extension notes do exist, for example Czech country/content entries at `overlays/extensions/hardcore/companion-translations/cs.yaml:1-15`, but the main `brainbrew.yaml` neither catalogs nor selects that overlay. The separate companion manifest demonstrates the correct sequencing by making it depend on field fills (`brainbrew-hardcore.yaml:70-79`). + +A fresh export of `cs-hardcore-standard` confirmed the duplicate Hardcore American Samoa note remains: + +- `American Samoa` +- `Unincorporated territory of the United States.` +- `Pago Pago` + +while only its GUID and note-type identity are localized. The translation summary likewise reported 2,202 untranslated fallbacks for `overlay.translation.hardcore.cs`. + +**Consumer impact.** Every non-English standalone Hardcore target can ship visibly mixed-language extension notes; Czech, Italian, Dutch, Polish, Portuguese, Russian, Swedish, and Chinese also retain English fill prose such as “Not a sovereign country” and “The capital is shared…”. Localized fill override files mitigate only German, Spanish, French, and Norwegian fill values, not the unselected extension-note translations. + +**Required direction.** Reuse the companion content translation overlays in the main manifest, or merge their dictionaries into a translation overlay that runs after both the shared extension and fills. Verify representative overlapping notes, not only note/GUID counts. + +### 3. High — duplicated hint variables have already produced mixed-language card faces in 13 UG languages + +**Evidence.** The base defines two variables with the identical source (`deck.yaml:14-15`) and uses them independently on question and answer (`templates/ultimate-geography/capital-country/question.html:8`, `answer.html:8`). Except for Hebrew, overlays translate only `label.capital-hint.answer`; Czech is representative (`overlays/languages/cs.yaml:712-724`) and Danish is the same (`overlays/languages/da.yaml:695-705`). Because variable translations are keyed by the exact variable key before direct fallback (`crates/brain-brew-core/src/translation.rs:259-267`), translating the answer key does nothing for the question key. + +The current Danish export contains `Hint: {{Capital hint}}` on the question and `Ledetråd: {{Capital hint}}` on the answer; Czech similarly mixes `Hint` and `Náznak`. Hebrew alone duplicates the same target under both keys (`overlays/languages/he.yaml:668-672`). Norwegian translates neither. + +**Consumer impact.** Standard, Extended, Experimental, and Hardcore outputs expose English UI text on one face despite having the target translation. The duplicated key also forces every translator to maintain two entries that are identical in almost every language. + +**Required direction.** Use one shared `label.capital-hint` variable unless a demonstrated language needs side-specific wording; side-specific languages can override at template scope. At minimum, add a parity/coverage guard requiring both keys when both remain in source. + +### 4. High — strict translation coverage is not compositional for split translation overlays + +**Evidence.** Coverage extracts the entire current deck for each translation overlay (`crates/brain-brew-core/src/translation.rs:48-154`). Verification checks each report independently and fails on any fallback (`crates/brain-brew-cli/src/commands/verify.rs:136-168`), then composes that overlay before checking the next (`crates/brain-brew-cli/src/commands/verify.rs:182-187`). Consequently, an extension-specific translation overlay is judged responsible for all already-translated base text plus every unrelated string in the current deck. + +On the real `cs-hardcore-standard` stack, summary output was: + +- base Czech overlay: 1,269 total fallbacks, 43 actionable text fallbacks; +- extension adapter overlay: 2,202 total fallbacks, 844 actionable text fallbacks. + +No UG target declares `translation_coverage: strict`, so normal all-target verification cannot catch findings 2 or 3 (`brainbrew.yaml:355-605`). + +**Consumer impact.** Maintainers cannot make a release gate strict while keeping translations split by base/extension responsibility. They must either ignore broad unrelated paths in every overlay, merge all dictionaries, or remain lenient. This undermines the documented language-specific extension-overlay workflow. + +**Required direction.** Define strict completeness for the target's combined translation stack (or attribute source units to the overlay that introduces them), rather than requiring every translation overlay to cover the whole intermediate deck. + +### 5. Medium — blank direct translations erase content globally while being reported as successfully translated + +**Evidence.** The model describes direct keys as replacements for exact non-empty source text (`crates/brain-brew-core/src/model.rs:700-710`), while docs reserve intentional divergence/supplementation for path-checked target adaptations (`documentation/docs/authoring/translations.md:11-21`). The implementation accepts an empty target, categorizes it as `DirectTranslation` without qualification (`crates/brain-brew-core/src/translation.rs:510-519`), and applies it as an ordinary replacement (`crates/brain-brew-core/src/translation.rs:1507-1511`, `1549-1558`). + +The real Danish dictionary relies on this to delete multiple source sentences globally (`overlays/languages/da.yaml:15-23`). + +**Consumer impact.** An accidentally blank YAML value silently deletes every occurrence and satisfies strict coverage; there is no path, reason, or explicit deletion intent to distinguish an intentional omission from unfinished translator input. Because `direct` is global, a later reuse of that sentence is also erased. + +**Required direction.** Reject blank direct/contextual/variable targets, or represent deletions as exact-path target adaptations with an expected source and optional reason. If blank faithful translations are intentionally supported, surface a distinct review category and warning. + +### 6. Medium — the YAML codec contains an undocumented UG-specific translation subtype + +**Evidence.** The generic formats crate hard-codes `"target addition from upstream UG"` (`crates/brain-brew-formats/src/canonical_yaml.rs:20`). Undocumented `translations.target_additions` values are converted into normal target adaptations carrying that magic reason (`crates/brain-brew-formats/src/canonical_yaml.rs:1754-1771`), then the formatter switches them back to the hidden shape only when that exact reason matches (`crates/brain-brew-formats/src/canonical_yaml.rs:730-744`). The public translation docs list only direct, contextual, no-change, target adaptations, and stale translations, and say top-level adaptations are emitted after the dictionary (`documentation/docs/authoring/translations.md:11-21`, `283-295`). Real UG language files depend on the hidden syntax (`overlays/languages/da.yaml:687-694`). + +**Consumer impact.** Core/API consumers see a product-branded synthetic reason that source authors never wrote. Changing or normalizing that reason changes the canonical YAML shape. Other deck authors cannot discover when to use this shorthand, and Brain Brew now has UG-specific behavior despite the stated general-purpose boundary. + +**Required direction.** Remove the alias after migrating UG to documented `target_adaptations`, or promote a product-neutral, documented `target_additions` concept with explicit stable semantics and no magic reason string. + +### 7. Medium — the actual UG workspace currently fails its documented verification gate and lags the checked fixture's authoring model + +**Evidence.** `brainbrew verify --manifest .../ultimate-geography/brainbrew.yaml --all-targets` stops immediately with `deck.yaml is not in canonical format`. Formatting a temporary copy showed the first delta at `deck.yaml:89-91` (unnecessary quoting of tags), followed by many equivalent deltas. The real manifest also lacks the `languages:`/`translation_profile:` metadata and any strict target policy through its end at `brainbrew.yaml:605`, while the checked fixture has already adopted those controls. + +**Consumer impact.** Contributors cannot run the skill/docs-prescribed all-target verification successfully against canonical source, and language-first tooling has to infer meaning from the large target/overlay naming convention. More importantly, a green fixture test does not establish that the actual consumer workspace is ready. + +**Required direction.** Canonically format the real workspace under its pinned Brain Brew version, adopt or explicitly reject the language catalog/profile, and run actual-workspace verification in CI. Keep fixture sync structural rather than maintaining a feature delta. + +### 8. Low — deck and note-type display identity use inconsistent variable strategies + +**Evidence.** The note-type name is variable-first (`deck.yaml:11-20`), but deck name/config name remain hardcoded (`deck.yaml:3-7`). Spanish then repeats the language suffix in both a `note-type.name` variable and a separate checked deck-name replacement (`overlays/languages/es.yaml:667-670`, `995-1005`); several other languages translate only the note-type name, so exported deck names and model names intentionally diverge. + +**Consumer impact.** Renames/suffix changes require two mechanisms and can drift. Current outputs already differ by language: e.g. Czech exports deck `Ultimate Geography` with model `Ultimate Geography [CS]`, while Spanish exports both with `[ES]`. + +**Required direction.** Decide whether deck and note-type identities are intentionally distinct. If not, introduce deck-level name/suffix variables and translate those rather than mixing variable translation with sparse property replacement. + +## Validation notes + +- `devenv shell -- cargo test -p brain-brew-core -p brain-brew-formats` passed all core/formats tests, including 55 overlay-compose tests, 21 overlay-YAML tests, and 18 UG fixture tests. +- Actual UG `verify --all-targets` failed on canonical formatting before composition. +- A fresh actual-workspace `cs-hardcore-standard` export succeeded and reproduced the untranslated extension note. +- A temporary minimal export reproduced the translated-field/broken-template-reference defect. +- No project/source files were modified; only this requested audit report was written. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only audit stayed within overlay/translation/source-variable/field-fill behavior and wrote only audit/03-overlays-translations.md." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Eight ranked findings include implementation, documentation, fixture, and real Ultimate Geography file:line evidence plus reproduced export/coverage behavior." + } + ], + "changedFiles": [ + "audit/03-overlays-translations.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell -- cargo test -p brain-brew-core -p brain-brew-formats", + "result": "passed", + "summary": "All core and formats unit/integration/doc tests passed, including UG fixture and overlay/translation suites." + }, + { + "command": "devenv shell -- cargo run -q -p brainbrew -- verify --manifest /home/jmo/Development/external/ultimate-geography/brainbrew.yaml --all-targets", + "result": "failed", + "summary": "Actual UG verification stopped because deck.yaml is not in canonical format." + }, + { + "command": "devenv shell -- cargo run -q -p brainbrew -- translations --manifest /home/jmo/Development/external/ultimate-geography/brainbrew.yaml --target cs-hardcore-standard --summary --json", + "result": "passed", + "summary": "Reported 1,269 fallbacks for the base Czech overlay and 2,202 for the Hardcore translation overlay." + }, + { + "command": "devenv shell -- cargo run -q -p brainbrew -- export crowdanki --manifest /home/jmo/Development/external/ultimate-geography/brainbrew.yaml --target cs-hardcore-standard --out /tmp/cs-hc-current", + "result": "passed", + "summary": "Fresh export showed the overlapping Hardcore American Samoa note and its country-info remained English." + }, + { + "command": "temporary ug-style export with translations.direct Capital: Hauptstadt", + "result": "passed", + "summary": "Export emitted field Hauptstadt while templates still referenced {{Capital}}, reproducing the semantic defect." + } + ], + "validationOutput": [ + "Core/formats test suites passed.", + "Actual UG all-target verify: deck.yaml is not in canonical format.", + "Czech Hardcore fresh export: extension-owned American Samoa fields remained English.", + "Translated field-name reproduction: final field Hauptstadt with unchanged {{Capital}} template references." + ], + "residualRisks": [ + "The actual UG verify failure prevents an end-to-end green all-target validation of the external workspace.", + "Only Czech was freshly exported for the dependency-order reproduction; manifest structure shows the same ordering for the other localized Hardcore targets.", + "No source fixes or regression tests were applied because the task was explicitly review-only." + ], + "noStagedFiles": true, + "notes": "plan.md and progress.md were absent. Jujutsu has no staging area; the working copy contains this and other requested audit reports, with no Brain Brew source changes from this review." +} +``` diff --git a/audit/04-yaml-formats.md b/audit/04-yaml-formats.md new file mode 100644 index 0000000..3aaa449 --- /dev/null +++ b/audit/04-yaml-formats.md @@ -0,0 +1,158 @@ +# Canonical YAML formats audit + +## Review + +### Blocker — duplicate keys in schema maps are accepted and silently overwrite maintainer source + +`deny_unknown_fields` is present on the containing structs, but entity/content maps are deserialized directly into `BTreeMap` (for example overlay maps at `crates/brain-brew-formats/src/canonical_yaml.rs:1406-1428`, note field changes at `:2122-2135`, deck entity maps at `:2316-2325`, manifest maps at `crates/brain-brew-formats/src/manifest.rs:624-639`, lock package maps at `crates/brain-brew-formats/src/lockfile.rs:148-154`, and the media-map root at `crates/brain-brew-formats/src/media_map.rs:13-21`). Duplicate keys in those maps are not rejected; the later value wins. This violates the strict-decoding and round-trip commitments in ADR-005 (`documentation/docs/reference/decisions/0005-store-maintainer-source-as-strict-canonical-yaml.md:13-24`) and the user-facing claim that canonical YAML is deliberately strict (`documentation/docs/concepts/canonical-deck.md:54-62`). + +Verified probes using `target/debug/brainbrew fmt`: + +- Two `field.capital` entries in one overlay note formatted successfully and retained only the second change. +- Two `media.flag.fi` entries in a standalone media map formatted successfully and retained only the second declaration. + +The existing duplicate-key test covers only duplicate struct fields (`base` twice) at `crates/brain-brew-formats/tests/manifest_yaml.rs:724-734`; it does not exercise duplicate keys inside dynamic maps. + +**Required fix/test:** use duplicate-detecting map deserialization (including nested maps and mappings first parsed through `serde_yaml::Value`) and add rejection tests for canonical entity IDs, note fields, overlay entities/field changes/translations, manifest overlays/targets/languages, lock packages, media-map IDs, and included media mappings. Diagnostics should identify the duplicate key and YAML location. + +### Blocker — contextual canonicalization can emit duplicate YAML and lose a valid translation on the second format pass + +The formatter groups contexts beginning with `notes.note.` / `note_types.note-type.` as child nodes (`crates/brain-brew-formats/src/canonical_yaml.rs:819-853`) and then emits a node's translation keys followed by child keys at the same indentation (`:855-874`). A source key can equal a child suffix, so duplicate-free accepted input can become duplicate-key output. + +Reproducer: + +```yaml +translations: + contextual: + notes.note: + denmark: Denmark label + notes.note.denmark: + Country: Danmark +``` + +The first `brainbrew fmt` emitted two `denmark:` keys under `notes.note`; the second `fmt` silently removed `denmark: Denmark label` because of the duplicate-map behavior above. This directly contradicts byte-idempotent canonicalization. The UG fixture checks only its current non-colliding data (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:183-229`), so it does not cover the legal key-space collision. + +**Required fix/test:** make the contextual representation/emitter unambiguous (or reject collisions before writing), and add parse → format → parse semantic-equality plus first-format/second-format byte-idempotence tests where a contextual source equals a descendant context segment for both grouped prefixes. + +### High — malformed overlay alternatives are accepted, and formatting silently discards supplied data + +Overlay decoding models mutually exclusive payloads as independent optional fields without enforcing exclusivity: + +- `FieldChangeYaml` accepts `value`, `message`, `format`, and `variables` together (`crates/brain-brew-formats/src/canonical_yaml.rs:2176-2190`). If `format` is present, positional `message` is ignored (`:2192-2205`); orphan `variables` are ignored when `format` is absent; a scalar `value` can coexist with a structured message (`:2206-2220`). Compose then gives the message precedence and ignores the scalar (`crates/brain-brew-core/src/compose.rs:1178-1205`). +- `MediaChangeYaml` accepts `path` and `sha256` independently and converts every partial pair to `media: None` (`crates/brain-brew-formats/src/canonical_yaml.rs:2249-2275`). + +Verified probes: + +- An overlay field containing scalar `value`, `format`, `variables`, and positional `message` formatted successfully but dropped the positional message; the scalar remained in YAML even though compose ignores it. +- An overlay field containing only orphan `variables` formatted successfully and removed the variables. +- An add-media change containing `path` but no `sha256` formatted successfully and removed the path. + +This is more severe than normalization of hand formatting: intentional schema data is discarded. Existing overlay tests reach only unknown top-level fields (`crates/brain-brew-formats/tests/overlay_yaml.rs:872-883`) and valid complete media (`:850-851`). + +**Required fix/test:** reject conflicting field payload branches, reject `variables` without `format`, require `path` and `sha256` together, and test every malformed combination through both `overlay_from_str` and `overlay_format_str` with path-specific diagnostics and unchanged input files on CLI failure. + +### Medium — YAML booleans, nulls, and numbers are coerced into strings instead of failing the typed schema + +Most schema text properties use plain `String` deserialization (for example canonical deck properties at `crates/brain-brew-formats/src/canonical_yaml.rs:2371-2380`), and field values explicitly try `serde_yaml::from_value::` (`:2525-2569`). `serde_yaml` coerces non-string scalars. Probes replacing a deck description with unquoted `true`, `null`, `123`, `1.5`, and `0x10` all formatted successfully into quoted strings (`'true'`, `'null'`, etc.). + +The emitter correctly quotes ambiguous strings (`crates/brain-brew-formats/src/yaml_scalar.rs:63-94`), but the decoder does not enforce that source values were YAML strings. In a format whose ADR explicitly calls for careful handling of ambiguous YAML values (`documentation/docs/reference/decisions/0005-store-maintainer-source-as-strict-canonical-yaml.md:28-30`), a missing value (`description:`/null) becoming the literal text `"null"` is especially surprising. + +**Missing tests:** reject non-string YAML scalar types for all string schema positions (content, IDs, paths, hashes, adapter IDs, versions, and translation keys/values), while continuing to accept explicitly quoted lookalikes. If coercion is intentional, document it explicitly and test every accepted conversion; strict-source wording currently implies rejection. + +### Medium — public emitters can panic on constructible domain values instead of returning an error + +Canonical deck emission is fallible and prevalidates keys, but overlay, manifest, and lock emitters are infallible public APIs. Their key helper calls `expect` (`crates/brain-brew-formats/src/canonical_yaml.rs:810`, `crates/brain-brew-formats/src/manifest.rs:281-315`, `crates/brain-brew-formats/src/lockfile.rs:54-85`). Public domain structs contain unrestricted `String` map keys, so a programmatically constructed overlay/manifest/lock with a newline key panics. Parse-path tests do not cover direct emission; `crates/brain-brew-formats/tests/emitter_roundtrip.rs:149-171` only verifies rejection while parsing (except canonical deck, whose emitter is already fallible). + +**Required fix/test:** make these emitters fallible and run the same prevalidation as their parsers, or make invalid keys unconstructible. Add `catch_unwind`-free tests asserting typed errors from direct emission for every unrestricted key location. + +### Low — include behavior and documentation have drifted, and invalid safe roots are silently ignored + +The reference says formatting scalar `!include` values materializes included content (`documentation/docs/reference/yaml.md:220-222`), while the implementation explicitly formats without materializing and restores include directives (`crates/brain-brew-formats/src/source_includes.rs:49-80`). CLI tests enforce preservation and non-inlining (`crates/brain-brew-cli/tests/cli.rs:5218-5259`), as do format-codec tests (`crates/brain-brew-formats/tests/canonical_yaml.rs:610-623`). The docs should describe the behavior users actually receive. + +Separately, configured safe include roots that cannot be canonicalized are silently skipped (`crates/brain-brew-formats/src/source_includes.rs:340-359`). A typo/nonexistent root then produces a misleading escape error later rather than identifying the invalid manifest root. The path containment check does correctly reject absolute paths and canonicalized symlink escapes (`:525-566`). + +**Missing tests:** invalid/nonexistent/unreadable safe roots with actionable diagnostics; scalar-include behavior as documented; and an explicit overlay `media: !include` rejection test (the reference says structural media includes are deck-only at `documentation/docs/reference/yaml.md:220`, but the resolver recognizes structural includes solely by YAML path at `source_includes.rs:386-389`). + +### Low — conversion diagnostics often omit the schema path and source location + +Serde shape errors generally carry line/column, but conversion errors such as `StableId`, `InvalidChangeIntent`, `InvalidExpectedBase`, and ordered-entity failures are returned after deserialization without retaining YAML location or a full schema path. For example, all IDs pass through unscoped `sid(...)` calls during map conversion (`crates/brain-brew-formats/src/canonical_yaml.rs:1431-1455`, `:2328-2363`). In a large UG source, `invalid stable id "..."` is insufficient to identify whether the bad value was an entity key, reference, order entry, or tombstone. + +**Missing tests:** nested malformed IDs/intents/order entries asserting file/schema path plus line/column (or, at minimum, an exact dotted schema path). Consider path-aware deserialization and path-bearing conversion error variants. + +### Correct / verified strengths + +- Unknown fields are denied broadly on schema structs, explicit order arrays are validated, stable scalar emission is conservative, and hostile control/newline values have round-trip coverage. +- The focused codec suite passed: 83 tests across canonical deck, overlay, emitter, scalar, manifest, lock, and media-map tests. +- Ultimate Geography remains a strong positive fixture: canonical checked-in YAML, byte idempotence, and all 74 main-manifest targets composed and validated in the focused runs (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:142-229`). The fixture's current clean key-space does not exercise the blockers above. +- `plan.md` and `progress.md` requested as inputs were not present at the supplied repository paths; review proceeded from ADRs, format docs, source, tests, and fixtures. + +## Acceptance report + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only task completed without modifying project/source files; only audit/04-yaml-formats.md was created." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings include exact code/doc/test references, reproducible formatter probes, focused test results, and explicit missing-test recommendations." + } + ], + "changedFiles": [ + "audit/04-yaml-formats.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell -- cargo test -p brain-brew-formats --test canonical_yaml --test overlay_yaml --test emitter_roundtrip --test yaml_scalar_adversarial --test manifest_yaml --test lockfile_yaml --test media_map", + "result": "passed", + "summary": "83 focused codec/formatting tests passed (24 + 21 + 4 + 4 + 20 + 5 + 5)." + }, + { + "command": "devenv shell -- cargo test -p brain-brew-formats --test ultimate_geography_fixture ultimate_geography_fixture_yaml_sources_are_checked_in_canonical", + "result": "passed", + "summary": "UG checked-in YAML canonicality test passed." + }, + { + "command": "devenv shell -- cargo test -p brain-brew-formats --test ultimate_geography_fixture ultimate_geography_fixture_formatting_is_byte_idempotent", + "result": "passed", + "summary": "UG fixture byte-idempotence test passed." + }, + { + "command": "devenv shell -- cargo test -p brain-brew-formats --test ultimate_geography_fixture ultimate_geography_fixture_manifest_composes_all_targets", + "result": "passed", + "summary": "All 74 UG main-manifest targets composed and validated." + }, + { + "command": "target/debug/brainbrew fmt ", + "result": "failed-review", + "summary": "CLI returned success while overwriting duplicate map entries, generated then consumed a contextual duplicate key, and discarded partial/conflicting overlay payload data; scalar-type probes were coerced to strings." + }, + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Pre-review working copy had no changes." + } + ], + "validationOutput": [ + "Duplicate overlay field key retained only the second value after fmt.", + "Duplicate media-map ID retained only the second declaration after fmt.", + "Contextual collision first fmt emitted duplicate denmark keys; second fmt removed the scalar translation.", + "Partial media change lost its path; orphan/conflicting structured-message branches were dropped or shadowed.", + "Unquoted true/null/123/1.5/0x10 deck descriptions were accepted and rewritten as quoted strings.", + "UG canonicality, idempotence, and 74-target composition checks passed." + ], + "residualRisks": [ + "No release-oracle CrowdAnki parity run was requested or performed; this audit focused on YAML codecs.", + "Malformed-input probes used temporary files and are not permanent regression tests.", + "Lock/package federation is documented experimental, but duplicate-map and emitter-panic behavior also affects non-experimental canonical/overlay/manifest surfaces.", + "plan.md and progress.md were absent at the requested paths." + ], + "noStagedFiles": true, + "notes": "Review gate should block on both duplicate-key findings and the overlay data-loss finding before accepting strict canonical YAML behavior." +} +``` diff --git a/audit/05-crowdanki.md b/audit/05-crowdanki.md new file mode 100644 index 0000000..1f72677 --- /dev/null +++ b/audit/05-crowdanki.md @@ -0,0 +1,140 @@ +# CrowdAnki import/export and round-trip audit + +## Review + +### Blocker + +1. **CrowdAnki import cannot import non-Latin decks whose first fields contain no ASCII, and the advertised correction path does not exist.** + - Note stable IDs are derived only from the first field (`crates/brain-brew-formats/src/crowdanki.rs:1181-1187`). `slugify` retains only ASCII alphanumerics and maps an all-non-ASCII value to `unnamed` (`crates/brain-brew-formats/src/crowdanki.rs:1289-1307`). The second such note therefore collides in the deck map (`crates/brain-brew-formats/src/crowdanki.rs:867-882`). + - Focused probe against the actual UG-generated `ru-standard/deck.json` failed on the first two Russian names: both derived `note.unnamed`. By contrast, the generated Chinese values happen to include English parentheticals and imported successfully; this is accidental rather than Unicode-safe. + - The collision diagnostic says to correct a “suggested-ID override path” (`crates/brain-brew-formats/src/crowdanki.rs:1212-1214`), but the public format API takes only JSON (`crates/brain-brew-formats/src/crowdanki.rs:84-88`) and the CLI exposes only blanket `--accept-suggested-ids` (`crates/brain-brew-cli/src/commands/import.rs:8-35`). There is no override/review artifact or retry input. + - This conflicts with ADR-004’s requirement that maintainers be able to review and correct suggested IDs before they become canonical (`documentation/docs/reference/decisions/0004-identify-deck-entities-with-human-readable-stable-ids.md:13-18,41-46`). It also makes collisions from ordinary repeated first fields unresolvable without mutating the CrowdAnki input itself. + - **Required direction:** make suggestion generation Unicode-capable and/or GUID-assisted, return a reviewable import plan, and implement an explicit stable-ID override input before requiring acceptance. + +### High + +2. **Duplicate note GUIDs are accepted and re-exported, so adapter identity is not fail-closed.** + - The importer checks duplicate note-model UUIDs (`crates/brain-brew-formats/src/crowdanki.rs:838-864`) but keys notes only by generated stable ID (`crates/brain-brew-formats/src/crowdanki.rs:867-882`); it never tracks GUID uniqueness. Core validation likewise checks duplicate stable IDs, not duplicate adapter-ID values (`crates/brain-brew-core/src/validate.rs:67-91`). + - A focused probe cloned one actual UG note, changed its first field so the stable ID differed, retained the original GUID, and imported it. The command exited 0 and emitted two notes with the same `crowdanki:guid`. Export then uses that value directly (`crates/brain-brew-formats/src/crowdanki.rs:641-649,686-691`). Such output cannot safely preserve Anki note identity/update behavior. + - Existing tests cover duplicate note-model UUIDs and stable-ID collisions, but not duplicate/empty note GUIDs (`crates/brain-brew-formats/tests/crowdanki.rs:245-329`). + - **Required direction:** reject duplicate or invalid GUIDs on import and reject any duplicate effective export GUID, including collisions between explicit GUIDs and stable-ID fallbacks. + +3. **Malformed template ordinals are silently normalized, changing card/template identity and order.** + - Field ordinals are validated against their positions (`crates/brain-brew-formats/src/crowdanki.rs:995-1006,1051-1066`), but templates are merely sorted by `ord`; `validate_supported_defaults` never checks ordinal uniqueness or contiguity (`crates/brain-brew-formats/src/crowdanki.rs:1008-1023,1085-1100`). Export discards imported ordinals and enumerates the canonical vector from zero (`crates/brain-brew-formats/src/crowdanki.rs:597-613`). + - Probe: changing UG’s first template ordinal from `0` to `99` imported successfully. Input order/ordinals were `Country - Capital:99, Capital - Country:1, Flag - Country:2, Map - Country:3`; re-export became `Capital - Country:0, Flag - Country:1, Map - Country:2, Country - Capital:3`. + - This violates ADR-010’s fail-closed rule for unsupported adapter data (`documentation/docs/reference/decisions/0010-fail-closed-on-unsupported-adapter-data.md:13-17,41-45`). There is no test for duplicate, gapped, or out-of-range template ordinals; current template fail-closed tests only cover browser-format fields (`crates/brain-brew-formats/tests/crowdanki.rs:619-662`). + - **Required direction:** after sorting, require ordinals to be exactly `0..n`, or model/preserve the adapter ordinal explicitly. + +4. **UG parity evidence is not wired into the maintained verification gate and is too weak to establish full CrowdAnki parity.** + - Neither `fixtures/ultimate-geography/brainbrew.yaml` nor `brainbrew-hardcore.yaml` configures `targets.*.exports.crowdanki.golden`; the fixture contains zero JSON oracles. The target block begins at `fixtures/ultimate-geography/brainbrew.yaml:355`, and its entries contain only overlay selections (for example `:356-413`). Consequently, `brainbrew verify ... --target en-standard` reports success without comparing CrowdAnki output: golden verification runs only when a target has both a CrowdAnki export and `golden` (`crates/brain-brew-cli/src/commands/verify.rs:303-320`). + - The actual external UG repository has a useful historical report based on Python Brain Brew 0.3.11 (`/home/jmo/Development/external/ultimate-geography/docs/pr736-equivalence-evidence.md:1-26`), but it covers only 12 representative rows (`:36-51`) while current manifests expose 74 UG and 26 Hardcore targets. The script explicitly compares selected semantics, not full JSON (`/home/jmo/Development/external/ultimate-geography/scripts/collect-pr736-equivalence-evidence.py:2-15`); its “model signature” is only `(model name, template count)` (`:300-301`) and omits template HTML/order, CSS, field definitions/order, config defaults, tags, and media path sets (`:319-355`). + - The in-repo fixture also intentionally differs from the actual checkout: it has structured `!image` fields and a hoisted `media.yaml`, while the external checkout still has raw image HTML and inline media (for example `fixtures/ultimate-geography/deck.yaml:83-91,5527` versus `/home/jmo/Development/external/ultimate-geography/deck.yaml:83-91,5515`). That is a useful migration fixture, but not an independent current-source oracle. + - **Required direction:** commit immutable old-tool `deck.json` oracles for a bounded representative matrix and configure manifest goldens. Use the exact JSON comparator with narrowly documented allowlists, plus explicit full-matrix identity/count checks where storing every target is impractical. + +### Medium + +5. **Core semantic diff omits data used by round-trip tests, allowing false “semantic equality.”** + - `CanonicalDeck::semantic_diff` compares deck name, description, and variables but not deck stable ID or deck adapter IDs (`crates/brain-brew-core/src/compose.rs:45-67`). Note comparison calls `diff_note_fields` only for raw `Note.fields` (`crates/brain-brew-core/src/compose.rs:1972-2013`), while the model also stores `field_messages` and `field_images` (`crates/brain-brew-core/src/model.rs:1117-1127`); neither structured map is compared (`crates/brain-brew-core/src/compose.rs:2017-2052`). + - The principal format round-trip assertion relies on this incomplete diff (`crates/brain-brew-formats/tests/crowdanki.rs:25-34`), as does the structured-image round trip (`:163-205`) and CLI UG-style round trip (`crates/brain-brew-cli/tests/ug_style_fixture.rs:113-124`). For example, the basic fixture omits deck-config adapter IDs, export synthesizes them, import adds them, and the test still passes because deck adapter IDs are invisible to semantic diff. + - **Required direction:** include deck ID/adapter IDs and structured field representations in semantic diff, or make round-trip tests compare a complete, explicitly normalized export projection instead of treating the current diff as complete. + +6. **The documented import command does not run.** + - CLI reference shows `brainbrew import crowdanki ... --out deck.yaml` without the mandatory acceptance flag (`documentation/docs/reference/cli.md:93-99`), while the implementation rejects every invocation lacking `--accept-suggested-ids` (`crates/brain-brew-cli/src/commands/import.rs:15-18`). Media documentation does mention the flag (`documentation/docs/concepts/media.md:57-60`), so the public docs are internally inconsistent. + - This should be corrected together with the suggested-ID review workflow, not by merely normalizing blanket acceptance as the long-term interface. + +### Correct + +- Import decoding is strict at each CrowdAnki object layer via `#[serde(deny_unknown_fields)]` (`crates/brain-brew-formats/src/crowdanki.rs:786-806,934-955,1039-1049,1069-1083,1103-1113`). +- Unsupported child decks, scheduling headers, cloze models, non-default model/field/browser options, opaque note data/flags, unknown model UUIDs, and field-count mismatches are rejected (`crates/brain-brew-formats/src/crowdanki.rs:808-830,958-989,1051-1066,1085-1100,1141-1178`). The 42 focused format tests exercise most of these paths and passed. +- Export determinism is structurally sound for supported data: BTree-backed collections drive note/model/media order, field and template vectors preserve canonical order, tags are set-sorted, JSON is pretty-serialized with a final newline (`crates/brain-brew-formats/src/crowdanki.rs:29-80,571-649`). The deterministic byte test passed (`crates/brain-brew-formats/tests/crowdanki.rs:9-23`). +- Raw template/CSS/field HTML is carried as strings. Strict whole-field image HTML is reverse-mapped conservatively, while mixed/non-strict/ambiguous HTML remains raw; positive and negative tests cover that behavior (`crates/brain-brew-formats/tests/crowdanki.rs:36-160`). +- Media export with `--media-root` validates hashes/files and copies only declarations (`crates/brain-brew-cli/src/commands/export.rs:86-101`); this is documented as conditional (`documentation/docs/concepts/media.md:61-73`). + +## Test gaps, ranked + +1. **High:** import and export rejection for duplicate/empty GUIDs and effective GUID fallback collisions. +2. **High:** Russian/non-Latin and repeated-first-field import with a real review/override flow. +3. **High:** template `ord` duplicate/gap/out-of-range cases and shuffled-but-valid ordinals. +4. **High:** immutable independent UG CrowdAnki goldens covering at least standard, translated non-Latin, extended, experimental, and Hardcore/companion outputs; assert template HTML/CSS, field/template order, tags, media paths, config, GUID/model UUIDs, and note fields. +5. **Medium:** semantic-diff tests proving deck IDs/adapter IDs, structured messages, and structured image references produce differences. +6. **Medium:** import/export of a real folder with media assets, followed by media hash/verification, so the expected asset handoff is explicit. +7. **Low:** CLI documentation/contract test that every documented import example includes the currently required acceptance/review arguments. + +## Commands and validation + +- `devenv shell cargo test -p brain-brew-formats --test crowdanki` — **passed**, 42/42. +- `devenv shell cargo test -p brainbrew --test ug_style_fixture` — **passed**, 1/1. +- `target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard` — **passed**, but no golden was configured, which is part of finding 4. +- Mutated actual UG `ru-standard/deck.json` import — **reproduced blocker**, failed because two Cyrillic first fields both became `note.unnamed`. +- Mutated actual UG duplicate-GUID import — **reproduced defect**, exited 0 and emitted duplicate `crowdanki:guid` values. +- Mutated actual UG template-ordinal import/export — **reproduced defect**, exited 0 and renumbered/reordered templates. +- `jj status`; `jj diff --stat` before report creation — clean working copy, 0 changed files. Final status also showed concurrent `audit/02-core-correctness.md` and `audit/04-yaml-formats.md` additions from other review tasks; this task wrote only `audit/05-crowdanki.md`. `git diff --cached --name-only` was empty. + +## Residual risks + +- I did not rerun the external historical Python 0.3.11 equivalence script: it bootstraps external dependencies and its checked-in report already records the last representative run. I inspected its implementation and report instead. +- I ran one in-repo UG target rather than all 74+26 targets because no target has a configured independent golden; running all would validate composition/media declarations but would not close the parity gap. +- The external UG working copy contains unrelated uncommitted files, so it was treated as read-only evidence and not as a clean acceptance baseline. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Completed the requested review-only CrowdAnki audit across core, formats, CLI, in-repo UG fixtures, and the external Ultimate Geography checkout; only audit/05-crowdanki.md was written." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings cite implementation, ADR, test, fixture, and external-oracle line ranges and include focused reproductions for non-Latin IDs, duplicate GUIDs, and template ordinal normalization." + } + ], + "changedFiles": [ + "audit/05-crowdanki.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell cargo test -p brain-brew-formats --test crowdanki", + "result": "passed", + "summary": "42 CrowdAnki format tests passed." + }, + { + "command": "devenv shell cargo test -p brainbrew --test ug_style_fixture", + "result": "passed", + "summary": "UG-style CLI compose/export/import semantic-diff test passed." + }, + { + "command": "target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard", + "result": "passed", + "summary": "One UG target verified; inspection confirmed it had no CrowdAnki golden comparison." + }, + { + "command": "focused mutated actual-UG import/export probes", + "result": "passed", + "summary": "Reproduced Russian note.unnamed collision, accepted duplicate GUIDs, and silent template ordinal normalization." + }, + { + "command": "jj status && jj diff --stat", + "result": "passed", + "summary": "Baseline was clean with 0 changed files before writing the requested report." + } + ], + "validationOutput": [ + "brain-brew-formats CrowdAnki tests: 42 passed, 0 failed", + "brainbrew ug_style_fixture: 1 passed, 0 failed", + "UG en-standard verify: verified 1 target", + "ru-standard import: unsupported collision at note.unnamed", + "duplicate-GUID import: exit 0 with duplicate crowdanki:guid", + "template ord round trip: [99,1,2,3] accepted and rewritten to [0,1,2,3] in a changed template order" + ], + "residualRisks": [ + "External Python 0.3.11 equivalence generation was inspected but not rerun.", + "Only one UG target was executed because the maintained manifests contain no independent CrowdAnki goldens.", + "External Ultimate Geography working copy was not clean and was used read-only." + ], + "noStagedFiles": true, + "notes": "plan.md and progress.md requested by the task were absent at repository root. This task changed only audit/05-crowdanki.md. Concurrent audit/02-core-correctness.md and audit/04-yaml-formats.md additions appeared before final status; git diff --cached --name-only remained empty." +} +``` diff --git a/audit/06-manifest-lock-media.md b/audit/06-manifest-lock-media.md new file mode 100644 index 0000000..571cff4 --- /dev/null +++ b/audit/06-manifest-lock-media.md @@ -0,0 +1,182 @@ +# Manifest, Lock, Source, and Media Audit + +## Review + +### Correct + +- **Target/overlay dependency expansion is deterministic and detects local and cross-package cycles.** Local expansion uses a visited set plus active stack (`crates/brain-brew-formats/src/manifest.rs:24-42,124-153`); package-aware expansion keys both package index and overlay ID (`crates/brain-brew-cli/src/io.rs:337-423`), and target inheritance has its own active stack (`crates/brain-brew-cli/src/io.rs:269-302`). Existing cycle coverage is in `crates/brain-brew-formats/tests/manifest_yaml.rs:825-850`. +- **File includes have materially better containment than ordinary package paths.** Include roots are canonicalized, absolute paths are rejected, lexical and canonical containment are checked, nested scalar cycles report a chain, and only top-level `media` is structurally spliced (`crates/brain-brew-formats/src/source_includes.rs:321-466,468-510`). `!image` is intentionally passed through (`source_includes.rs:402`). +- **ADR-0016 structured image behavior is substantially implemented.** Unknown stable IDs fail during rendering with a field path, and rendering emits the specified exact tag (`crates/brain-brew-core/src/compose.rs:1776-1805`). Verification resolves structured IDs while retaining raw HTML/CSS scanning (`crates/brain-brew-formats/src/media.rs:45-70,136-175`). Structural media maps are parsed and formatted separately, and hash writeback follows a base `media: !include` (`crates/brain-brew-cli/src/commands/media.rs:307-340`). +- **Real Ultimate Geography usage exercises the intended severable shape.** `fixtures/ultimate-geography/deck.yaml:5527` uses `media: !include media.yaml`; repository inspection found 602 `!image` uses, zero remaining strict raw `` fields, 546 declarations, and no duplicate declaration paths. All 67 manifest overlay catalog IDs/kinds match their files. `brainbrew targets --json` reported 74 targets and `brainbrew verify --all-targets` passed all 74. +- **Warm-cache lock behavior is reproducible when a valid hash is present.** `fetch_locked_source_with_mode` prefers a cache entry only after re-hashing it (`crates/brain-brew-cli/src/commands/lock.rs:430-455,584-601`), while `lock verify` deliberately re-reads live path sources. Existing tests prove path drift detection and relocatable relative locks (`crates/brain-brew-cli/tests/lock_cli.rs:6-120`). A warm git/tarball cache therefore supports offline reads; a cold cache necessarily fetches. + +### Blocker — locked package manifests can escape the hashed source tree + +**Refs:** `crates/brain-brew-cli/src/commands/lock.rs:48,68,107-108,474-475`; `crates/brain-brew-cli/src/io.rs:94-96,298-310,415-420`; ADR-0009 requires locks to be reproducible package inputs (`documentation/docs/reference/decisions/0009-use-manifests-targets-and-locks-before-a-recipe-dsl.md:15-17,41-45`). + +`Path::join` discards the fetched source prefix when `package.manifest` is absolute, and neither `lock update` nor lock parsing requires the manifest to be a contained relative path. The same issue applies to manifest `base` and overlay `file` values: they are joined and read without the canonical containment checks used for `!include`. Preserved source-tree symlinks (`commands/lock.rs:796-824,834-845`) provide another escape route because later `read_to_string` follows them. + +**Verified scenario:** an audit fixture locked an unrelated source directory while passing an absolute `--package-manifest /tmp/.../host/brainbrew.yaml`. After lock creation, the host deck outside the NAR-hashed source was changed from `Original Host Deck` to `Mutated Outside Lock`. `brainbrew lock verify` still printed `verified 1 locked package`, and a downstream locked compose emitted `name: Mutated Outside Lock`. The lock contained the absolute manifest path and a hash covering only the unrelated source bytes. + +**Impact:** a lock can claim one hash while composition reads mutable/unhashed files; a package can also make Brain Brew read host paths outside its snapshot. This defeats the central ADR-0009 integrity guarantee and is a package trust-boundary failure. + +**Required fix/test:** require `manifest`, `base`, and overlay `file` to be non-empty package-relative paths; canonicalize the final existing target and reject absolute, `..`, and symlink escapes from the fetched package root. Add CLI tests for absolute and parent manifest paths, absolute/parent base and overlay paths, and in-tree symlinks targeting outside the snapshot. + +### High — hand-edited locks may be completely unpinned and still verify + +**Refs:** `crates/brain-brew-formats/src/lockfile.rs:32-40,176-198,215-229`; `crates/brain-brew-cli/src/commands/lock.rs:94-108,430-454,584-587`. + +`locked.nar_hash` is optional, and lock parsing validates neither required fields per source type nor hash/revision syntax. With no hash, `cached_source` returns `None`, the live source is fetched/snapshotted, and hash comparison is skipped. A `locked` path, tarball, or git source can therefore remain mutable despite being accepted as a lock. + +**Verified scenario:** a manual path lock with no `nar_hash` was verified successfully; after mutating its deck, the same unchanged lock verified successfully again. Existing lockfile tests only require the `locked` mapping itself (`crates/brain-brew-formats/tests/lockfile_yaml.rs:85-106`), and CLI lock tests cover only locks emitted by `lock update`. + +**Required fix/test:** make a valid SRI SHA-256 mandatory in every `locked` source; require exactly the fields appropriate to `path`, `git`, or `tarball`, and require a resolved git revision. Add malformed/unpinned lock tests and a cold-cache offline diagnostic test. An explicit `--offline` mode would also give predictable fail-fast UX instead of attempting network access. + +### High — ordinary `verify` accepts empty or malformed media checksums + +**Refs:** `crates/brain-brew-cli/src/commands/verify.rs:47-75`; `crates/brain-brew-cli/src/media_assets.rs:8-26`; `crates/brain-brew-formats/src/media.rs:89-127`; ADR-0016 verification contract at `documentation/docs/reference/decisions/0016-use-structured-image-field-references-and-severable-media-includes.md:100-109`. + +Without `--media-root`, verification calls only `validate_media_references`; checksum validation, including the explicit `EmptyHash` check, runs only when asset bytes are supplied. Thus source can be reported verified while its media declarations are not content-addressed at all. + +**Real UG evidence:** `fixtures/ultimate-geography/media.yaml:1-9` begins with empty `sha256` values (and the declaration file uses this pattern throughout), yet `devenv shell brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets` reported `✓ verified 74 targets`. This is weaker than ADR-0016's statement that structured references proceed through path, SHA-256, and on-disk integrity checks. + +**Required fix/test:** always validate checksum presence and canonical 64-character lowercase hex syntax; with `--media-root`, additionally compare bytes and require assets. Add a CLI test proving no-media-root verification rejects empty/malformed checksums, while a separate diagnostic clearly says when byte validation was not requested. + +### High — media migration/hash commands can rewrite locked dependency cache entries using the wrong media root + +**Refs:** `crates/brain-brew-cli/src/commands/media.rs:40-74,89-116,285-299,358-379`; package-qualified overlays resolve to the dependency package path in `crates/brain-brew-cli/src/io.rs:395-420`. + +`collect_manifest_source_files` inserts every planned overlay file, including package-qualified overlays fetched from `brainbrew.lock`. Both `media hash` and `media images-to-refs` then write those paths in place. For a locked dependency, that path is under the content-addressed cache. `media hash` also resolves every asset against the root package's single `--media-root`, not the owning overlay package's root. + +**Scenario:** a downstream target selects `upstream:overlay.extension.media`; running either media mutation command can alter `~/.cache/brainbrew/sources//...`. The next cache read notices a NAR mismatch and deletes the cache (`commands/lock.rs:592-601`); a remote dependency that was previously usable offline now needs a refetch. Hashing can additionally write hashes for downstream files into upstream declarations when relative media paths overlap. + +**Required fix/test:** source collection must retain package ownership and mutability. Mutating commands should modify only the root workspace by default, reject locked/cache sources, and require an explicit per-package operation for dependencies. Add locked package-qualified overlay tests asserting cache bytes/NAR hash remain unchanged. The export/hash model also needs per-package media-root provenance before federated packages with independent asset trees are reliable. + +### High — dependency validation is bypassed for explicit `--include`; package cycles and compatibility metadata are not enforced + +**Refs:** `crates/brain-brew-cli/src/io.rs:202-241`; identical gate in `crates/brain-brew-cli/src/commands/targets.rs:27-38`; `crates/brain-brew-cli/src/package_resolver.rs:16-61`; `crates/brain-brew-formats/src/manifest.rs:159-165,670-688`. + +Dependency validation runs only when `--package-root` is non-empty or lock paths were found, not when manifests are supplied via `--include`. The validator checks only presence and optional exact `@version`; it does not detect package dependency cycles. `compatible_base_versions` is parsed, emitted, and displayed but has no enforcement consumer (the real UG manifest declares `>=0.1,<0.2` at `fixtures/ultimate-geography/brainbrew.yaml:1-5`). + +**Verified scenario:** an explicitly included upstream package declared `depends_on: example.missing@1.0.0`; a root target extended it and composition succeeded. The same metadata would fail if discovered through `--package-root` or a lock. Cyclic A→B→A metadata likewise passes the current validator as long as both IDs exist. + +**Required fix/test:** validate the complete registry whenever more than the root manifest is loaded, irrespective of discovery mechanism; reject malformed dependency strings and dependency cycles with a package chain. Define and test the semantics of `compatible_base_versions` or remove it until enforceable. Add parity tests showing `--include`, `--package-root`, and lock discovery produce the same dependency result. + +### Medium — package-qualified targets compose but `targets --json` cannot expand them + +**Refs:** package-aware resolver `crates/brain-brew-cli/src/io.rs:337-457`; JSON path `crates/brain-brew-cli/src/commands/targets.rs:40-59`; local-only expansion `crates/brain-brew-cli/src/io.rs:460-508` and `crates/brain-brew-formats/src/manifest.rs:24-42`. + +The JSON listing expands each manifest without a registry, so a documented package-qualified overlay is looked up literally in the local catalog. Text listing works because it does not expand. + +**Verified scenario:** a target extending `example.up:base` and selecting `example.up:overlay.patch` composed through the package-aware planner, but `brainbrew targets --manifest root --include up --json` failed with `manifest overlay "example.up:overlay.patch" does not exist`. + +**Required fix/test:** use the same registry/planner for compose, explain, verify, and target JSON; add JSON coverage for qualified `extends`, qualified overlays, transitive overlay dependencies, and target/overlay cycles with full package-qualified chains. + +### Medium — manifest overlay identity/kind is advisory and can diverge from the loaded overlay + +**Refs:** catalog fields are accepted as arbitrary optional strings (`crates/brain-brew-formats/src/manifest.rs:168-174,692-708`); loading reads the file but does not compare catalog ID/kind with `Overlay.id`/`Overlay.kind` (`crates/brain-brew-cli/src/io.rs:314-325,382-421`). Documentation says the manifest kind should match (`documentation/docs/authoring/manifests-targets.md:38-52`). + +A catalog key can point to a file with a different `id`, and `kind: translation` can point to an extension. Expansion/explain reports the catalog identity, while compose conflict provenance and translation tooling use the file identity/kind. Duplicate file IDs under distinct catalog keys can also yield misleading self-conflict diagnostics. + +**Real UG evidence:** an audit script checked all 67 catalog entries and found zero ID/kind mismatches, so the fixture is disciplined; the loader does not guarantee this for other packages. + +**Required fix/test:** validate catalog key == overlay file ID and catalog kind == parsed overlay kind during planning/verify; reject unknown manifest kinds. Add mismatch tests, including two catalog entries whose files share one ID. + +### Medium — media path safety is checked too late and is not symlink-safe; export may skip reference validation + +**Refs:** media paths are only lexically checked for absolute/parent components (`crates/brain-brew-cli/src/media_assets.rs:29-45,48-76`; duplicate logic at `commands/media.rs:618-627`); structured rendering interpolates the raw path into HTML (`crates/brain-brew-core/src/compose.rs:1782-1804`); export validates media only when `--media-root` is supplied (`crates/brain-brew-cli/src/commands/export.rs:86-100`). + +A media root containing an in-tree symlink to an external directory passes the lexical check, and `fs::read`/`fs::copy` follows it. This matters especially because package snapshots preserve symlinks. Paths containing `"`, `<`, `>`, CR, or LF are also accepted and are interpolated unescaped into ``, even though ADR-0016's strict reverse mapper explicitly excludes those characters. Finally, an export without `--media-root` does not call `validate_media_references`, so raw HTML can name an undeclared asset and still produce `deck.json`. + +**Required fix/test:** validate media path syntax in the domain/format layer, reject URI-like and HTML-breaking paths, canonicalize each existing source under canonical media root before reading/copying, and always validate declaration/reference consistency before export. Add Unix symlink-escape, quote/newline path, and no-media-root raw-reference export tests. + +### Note — source verification is not transitive across target inheritance + +`verify` formats the root manifest/base and only `plan.overlays` retained after inherited targets have already been composed (`crates/brain-brew-cli/src/commands/verify.rs:38-60`; `crates/brain-brew-cli/src/io.rs:298-334`). Upstream inherited overlays and included media-map formatting are therefore not independently checked by a downstream verify. This is lower risk for a correctly hashed dependency but weakens source-quality UX. A registry plan should retain transitive source provenance so verify can report what was checked without rewriting dependency sources. + +## Missing test matrix + +1. Lock containment: absolute/`..`/symlink manifest, base, and overlay paths. +2. Lock semantics: absent/malformed NAR hash, illegal source-field combinations, absent git revision, warm/cold offline behavior. +3. Discovery parity: `--include` vs `--package-root` vs lock, package dependency cycles, malformed dependency specs, compatibility ranges. +4. Registry UX: package-qualified `targets --json`, full cycle chains, manifest/file ID and kind mismatches. +5. Federated media ownership: package-qualified overlays under lock; mutation commands must not touch cache/dependency files or use another package's media root. +6. Media integrity: empty/malformed checksum without media root, raw undeclared reference during export, symlink escapes, HTML-breaking paths. +7. Transitive verify: inherited base/overlay/media-map formatting and clear reporting of dependency source verification. + +## Audit constraints + +- Requested `plan.md` and `progress.md` did not exist at the supplied paths; review proceeded from ADRs, docs, implementation, tests, fixtures, and executable reproductions. +- Review only: no project/source files or tests were modified. This audit report is the only requested output file. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Completed the requested manifest/target/overlay/package-lock/source/include/media review without modifying project source; wrote only audit/06-manifest-lock-media.md." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings include exact file/line references, executable scenarios, real Ultimate Geography counts and 74-target verification, ranked impact, and explicit missing tests." + } + ], + "changedFiles": [ + "audit/06-manifest-lock-media.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell cargo test -p brain-brew-formats --test manifest_yaml --test lockfile_yaml --test media_map --test media_references", + "result": "passed", + "summary": "44 focused format/manifest/lock/media tests passed." + }, + { + "command": "devenv shell cargo test -p brainbrew --test lock_cli --test media_includes", + "result": "passed", + "summary": "5 focused CLI lock/include tests passed." + }, + { + "command": "devenv shell brainbrew targets --manifest fixtures/ultimate-geography/brainbrew.yaml --json", + "result": "passed", + "summary": "Reported 74 Ultimate Geography targets." + }, + { + "command": "devenv shell brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets", + "result": "passed", + "summary": "Verified all 74 targets; also demonstrated that empty media hashes are not checked without --media-root." + }, + { + "command": "temporary-fixture lock escape, unpinned-lock, explicit-include dependency, and qualified-target JSON reproductions under /tmp", + "result": "passed", + "summary": "Reproduced mutable out-of-hash manifest composition, acceptance of hashless locks, dependency-validation bypass, and targets --json failure." + }, + { + "command": "jj status && jj diff --stat (before and after review)", + "result": "passed", + "summary": "Pre-review working copy was clean; post-review status showed this report plus four concurrent audit reports, with no project/source edits." + }, + { + "command": "python3 acceptance-report JSON validation", + "result": "passed", + "summary": "Confirmed the final fenced acceptance-report is valid JSON and names this audit file." + } + ], + "validationOutput": [ + "Ultimate Geography: 74 targets; 602 !image uses; 546 media declarations; 0 strict raw image fields; 0 duplicate media paths; 0 overlay catalog ID/kind mismatches.", + "Lock escape reproduction: lock verify succeeded after an out-of-hash host deck changed, and compose emitted the changed name.", + "Unpinned lock reproduction: verification succeeded before and after mutation with zero nar_hash fields.", + "Explicit --include reproduction: composition succeeded despite a missing declared package dependency.", + "Qualified target reproduction: targets --json failed with manifest overlay package-qualified ID does not exist." + ], + "residualRisks": [ + "No full workspace test/CI run was needed for this review; focused suites and real UG all-target verification passed.", + "Cold-cache network behavior was inspected in code but not exercised against live GitHub/tarball services.", + "Federated media cache mutation and media-root symlink escape were established from direct filesystem call paths but not executed against a real remote package." + ], + "noStagedFiles": true, + "notes": "plan.md and progress.md were absent. Jujutsu is used, so there is no Git staging area; no source edits were made. Post-review status also contained audit/02-core-correctness.md, audit/04-yaml-formats.md, audit/05-crowdanki.md, and audit/08-workbench-backend.md from concurrent reviewers; they were not created or modified by this review." +} +``` diff --git a/audit/07-cli-ux.md b/audit/07-cli-ux.md new file mode 100644 index 0000000..bb3cb88 --- /dev/null +++ b/audit/07-cli-ux.md @@ -0,0 +1,222 @@ +# CLI maintainer UX audit + +## Review + +### Correct + +- The core read/report flows work against the repository's Ultimate Geography case study. `targets` returned 74 main targets and 100 targets when both manifests were supplied; `validate --target de-extended` reported 319 notes, one note type, six templates, and 546 media references; `explain --json` returned three overlays and 753 semantic changes. This agrees with the documented 74/26 split in `documentation/docs/examples/ultimate-geography.md:35-63`. +- Both documented UG verification gates passed exactly: + - `target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets` -> exit 0, `✓ verified 74 targets`. + - `target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew-hardcore.yaml --all-targets` -> exit 0, `✓ verified 26 targets`. +- Human/JSON stream separation is good for the commands covered by the documented contract. `explain ... --target does-not-exist --json` returned exit 1, 1,422 bytes of JSON on stdout, and empty stderr, matching `documentation/docs/reference/cli.md:9-33` and the dispatcher in `crates/brain-brew-cli/src/main.rs:15-27,82-87`. +- Workbench starts on loopback with an ephemeral port and is script-discoverable: `brainbrew workbench serve ... --port 0 --no-open` printed `Workbench listening at http://127.0.0.1:37275`; `/api/health` returned status `ok`, and `/api/workspace` exposed 74 targets, 16 languages, and 69 fingerprints. The loopback bind is explicit at `crates/brain-brew-cli/src/commands/workbench.rs:162-177`. +- Package locking failed closed in the exercised path flow. `lock update --package anki-geo.ultimate-geography --path fixtures/ultimate-geography` and `lock verify` both exited 0; adding a temporary file to the live package made verify exit 1 with the expected/found NAR hashes. The check is wired at `crates/brain-brew-cli/src/commands/lock.rs:94-105`. +- A CrowdAnki export/import round trip produced a Canonical Deck that `validate` accepted, and `fmt` followed by `validate` remained green. Existing fixture coverage also checks semantic round-trip equivalence at `crates/brain-brew-cli/tests/ug_style_fixture.rs:99-120`. + +### Severity-ranked findings + +#### S1 — High: Workbench “atomic” multi-file apply can commit only part of an edit + +`write_files_atomically` prepares and fsyncs all temporary files, but then renames them sequentially (`crates/brain-brew-cli/src/commands/workbench.rs:3878-3963`). If rename 2 fails after rename 1, there is no rollback. The API returns HTTP 500 while canonical source is already changed. + +This is not hypothetical: the checked-in test deliberately injects a failure at rename index 1 and asserts the response says `updated files: deck.yaml` and `not updated files: da.yaml, nb.yaml` (`crates/brain-brew-cli/tests/cli.rs:1153-1184`). Command evidence: + +```text +$ devenv shell cargo test -p brainbrew --test cli \ + workbench_apply_rename_failure_reports_updated_and_not_updated_files -- --exact +running 1 test +... ok +test result: ok. 1 passed +``` + +For an authoring UI that may update base plus several translation overlays in one apply (`workbench.rs:1007-1035`), a reported failure can therefore leave source semantically inconsistent. The helper should either implement backup/rollback/recovery metadata or stop presenting this as an atomic transaction and make recovery a first-class flow. + +#### S1 — High: `media hash` writes source files before knowing the whole operation can succeed + +The command processes sources in a loop and immediately calls `fs::write` for each changed file (`crates/brain-brew-cli/src/commands/media.rs:46-74`). A later missing media asset leaves earlier canonical sources modified even though the command exits 1. + +Adversarial evidence used a valid target whose base declared existing `good.png` and whose extension declared missing `missing.png`: + +```text +$ brainbrew media hash --manifest /tmp/.../brainbrew.yaml \ + --all-targets --media-root /tmp/.../assets +exit=1 base-hash-now-empty=no overlay-hash-empty=yes stdout-bytes=0 +/tmp/.../assets/missing.png: No such file or directory (os error 2) +``` + +The base's hash was changed to `770e6076...`, while the overlay remained untouched. `media images-to-refs` has the same write-as-you-go shape at `media.rs:89-116`. All reads, transformations, and validation should complete before a transaction writes any source. + +#### S1 — High: Import overwrites canonical source without `--force`, atomics, or even strict argument parsing + +Import writes directly to the requested path (`crates/brain-brew-cli/src/commands/import.rs:27-36`). There is no existence check, force flag, staging file, backup, or parent transaction. In the exercised round trip: + +```text +$ printf 'DO NOT OVERWRITE\n' > imported.yaml +$ brainbrew import crowdanki export/ --accept-suggested-ids \ + --out imported.yaml --bogus +exit=0 sentinel-preserved=no +stdout="imported crowdanki deck" +``` + +This can destroy maintainer-owned source, and the same invocation silently accepted `--bogus`. The parser merely searches for the first `--out` and ignores everything else (`crates/brain-brew-cli/src/args.rs:295-302`), directly contradicting the promise that unknown options are rejected in `documentation/docs/reference/cli.md:7`. Import needs a complete parser plus create-new-by-default/`--force` behavior and atomic replacement. + +#### S2 — Medium: Media-complete verification/export is opt-in, and successful output does not say assets were skipped + +Both full UG verifies passed with 546 declarations but no media root. In code, `verify` checks actual bytes/hashes only when `--media-root` is supplied; otherwise it checks references alone (`crates/brain-brew-cli/src/commands/verify.rs:47-79`). Likewise, export only validates/copies media under `Some(media_root)` (`crates/brain-brew-cli/src/commands/export.rs:86-106`). + +Concrete output from an export without `--media-root` was a success report saying `media references: 546`, while the fresh output contained only `deck.json`. The reference docs disclose the distinction (`documentation/docs/reference/cli.md:141-149`), but the success message does not. A CI gate or release export can therefore be green and incomplete due to one omitted flag. At minimum, report `media assets: not checked/not copied` prominently; preferably permit a manifest-configured media root and make release verification fail closed when media is declared but assets are not checked. + +#### S2 — Medium: Export reuses dirty output directories and is not transactional + +`write_crowdanki_export` creates/reuses the output directory, overwrites `deck.json`, then copies media (`crates/brain-brew-cli/src/commands/export.rs:94-101`). It neither cleans stale files nor stages a complete replacement. Evidence: + +```text +$ brainbrew export crowdanki ... --out /tmp/.../export +$ echo sentinel > /tmp/.../export/stale-from-old-export.bin +$ brainbrew export crowdanki ... --out /tmp/.../export +first-exit=0 second-exit=0 stale-file-after-rerun=yes files=2 +``` + +Old media or unrelated sensitive files can be unintentionally included when the directory is archived. A copy failure after line 96 can also leave new `deck.json` with partial/old media. Export should stage a clean sibling directory and rename it into place, or require/offer an explicit clean mode and enumerate retained files. + +#### S2 — Medium: The documented first compose fails when `build/` does not already exist + +The reference, tour, quickstart, and built-in help all show `--out build/...` (`documentation/docs/reference/cli.md:68-74`, `documentation/docs/getting-started/cli-tour.md:27-33`, `documentation/docs/getting-started/quickstart.md:82-90`, `crates/brain-brew-cli/src/help.rs:48-49`), but compose calls `fs::write` without creating the parent (`crates/brain-brew-cli/src/commands/compose.rs:31-34,74-76`). Evidence from a fresh temp tree: + +```text +$ brainbrew compose ... --out /tmp/.../missing/build/en.yaml +exit=1 output-exists=no stdout-bytes=0 +/tmp/.../missing/build/en.yaml: No such file or directory (os error 2) +``` + +Export already creates its output directory. Compose should likewise create the parent or docs must include `mkdir -p build` everywhere. + +#### S2 — Medium: The import onboarding/review flow is internally inconsistent and incomplete + +The CLI refuses import without `--accept-suggested-ids` (`crates/brain-brew-cli/src/commands/import.rs:15-18`), yet the reference's only import command omits that required flag (`documentation/docs/reference/cli.md:93-99`). Running it exactly returned exit 1: `non-interactive CrowdAnki import requires --accept-suggested-ids for now`. + +The built-in help exposes only all-at-once acceptance (`crates/brain-brew-cli/src/help.rs:54-55`); there is no dry-run/suggestions report or selective correction flow before canonical output. There is also no `init` command (`brainbrew init` -> exit 1, `unknown command "init"`), and top-level help lists no workspace scaffold (`help.rs:8-21`). Thus the current starting path is “accept every generated ID into a full deck, then manually build a manifest/workspace.” Add an inspectable import plan or temp-output review workflow and either an init/scaffold command or an explicit documented manual equivalent. + +#### S2 — Medium: Parse failures lose the source filename + +Read failures include paths, but successful reads followed by deck/overlay/manifest parse errors discard them (`crates/brain-brew-cli/src/io.rs:68-96`). Evidence: + +```text +$ brainbrew targets --manifest /tmp/.../bad-manifest.yaml +exit=1 +failed to parse manifest YAML: base: invalid type: sequence, expected a string at line 1 column 7 + +$ brainbrew validate fixtures/ultimate-geography/deck.yaml \ + --overlay /tmp/.../bad-overlay.yaml +exit=1 +invalid overlay kind "wat" +``` + +Neither diagnostic names the malformed file. That becomes especially confusing for `verify --all-targets` and package/include stacks. Wrap every parser error with the display path and, for target operations, target/overlay context. + +#### S2 — Medium: `translations --json` has JSON success but plain-text failure + +Help advertises `translations ... --json` (`crates/brain-brew-cli/src/help.rs:66-67`), and a UG summary returned valid JSON with empty stderr. But JSON error dispatch only recognizes validate/explain/diff/targets (`crates/brain-brew-cli/src/main.rs:82-87`). Evidence: + +```text +$ brainbrew translations --manifest /tmp/definitely-missing-brainbrew.yaml \ + --target de-standard --json +exit=1 stdout-bytes=0 +stderr="No Brain Brew manifest found ..." +``` + +A script cannot consistently parse one selected output mode. Either extend the JSON error envelope to translations or remove/qualify its machine-readable claim. + +#### S3 — Low: `diff` cannot directly serve as a CI “fail if changed” gate + +A one-change comparison printed `1 semantic change` but exited 0. The implementation always returns `Ok(())` after reporting (`crates/brain-brew-cli/src/commands/diff.rs:20-35`), and help/reference expose no `--exit-code`/`--quiet` mode (`documentation/docs/reference/cli.md:101-109`). JSON makes custom scripting possible, but a conventional optional `--exit-code` would make this discoverable and robust. + +#### S3 — Low: Global help/version bypass argument validation despite the documented strictness + +The dispatcher returns command help whenever *any* argument is `--help` before parsing the rest (`crates/brain-brew-cli/src/main.rs:38-47`), and version ignores trailing arguments (`main.rs:67-70`). Evidence: + +```text +$ brainbrew compose --bogus --help # prints help, exit 0 +$ brainbrew --version --bogus # prints version, exit 0 +``` + +This conflicts with the blanket rejection statement at `documentation/docs/reference/cli.md:7`. It is low impact, but strict parsing claims should either exclude help/version or behavior should be tightened. + +### Notes and residual risks + +- `plan.md` and `progress.md` named in the task were absent at the supplied paths, so there was no plan/progress context to inspect. +- No external checkout of upstream Ultimate Geography existed under `/home/jmo/Development/projects`; only `fixtures/ultimate-geography` and its duplicate under `default/` were available. The docs say this fixture merely “mirrors” upstream (`documentation/docs/examples/ultimate-geography.md:5-7`). The 74/26 verification is therefore strong repository-fixture evidence, not attestation against a live upstream checkout or real media tree. +- Direct, non-atomic `fs::write` remains in other mutators: `fmt` (`crates/brain-brew-cli/src/commands/fmt.rs:18-23`), lock update (`commands/lock.rs:76-81`), compose (`commands/compose.rs:31-34,74-76`), and both media migrations (`commands/media.rs:58-69,111-116`). Disk-full/process-kill behavior was not fault-injected for all of them. +- The full browser interaction surface was not exercised with a real browser; only server startup and JSON health/workspace APIs were tested. Existing repository browser/API tests reduce but do not eliminate that residual risk. + +## Command evidence summary + +| Flow | Command/result | +|---|---| +| Build | `devenv shell cargo build -p brainbrew` -> exit 0 | +| Help/init | `brainbrew --help` -> 0; `brainbrew init` -> 1 unknown command | +| Targets | main manifest -> 74 lines; both manifests -> 100 lines; JSON parsed | +| Validate | UG `de-extended` -> 0, 319 notes/6 templates/546 media refs | +| Compose | valid composition logic; missing output parent -> 1 | +| Explain | UG JSON -> 3 overlays/753 changes; missing target JSON -> 1 with stderr empty | +| Verify | main 74 and Hardcore 26 targets -> both 0 | +| Export/import/fmt | export -> 0; import -> 0 and validated; fmt then validate -> 0 | +| Diff | one semantic change -> exit 0 | +| Lock | update/verify -> 0; live package mutation -> verify 1 | +| Workbench | loopback server, health/workspace JSON -> 0; targeted rename-failure test -> pass | +| Media mutation | later missing asset -> exit 1 after earlier source changed | +| Translation JSON | success JSON -> 0; missing manifest with `--json` -> plain stderr/1 | + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Severity-ranked review findings include exact command results and file:line references across help, import, fmt, validate, compose, diff, export, targets, explain, verify, lock, media, translations, and workbench." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell cargo build -p brainbrew", + "result": "passed", + "summary": "Fresh CLI build completed." + }, + { + "command": "target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets", + "result": "passed", + "summary": "Verified all 74 main UG fixture targets." + }, + { + "command": "target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew-hardcore.yaml --all-targets", + "result": "passed", + "summary": "Verified all 26 Hardcore companion fixture targets." + }, + { + "command": "devenv shell cargo test -p brainbrew --test cli workbench_apply_rename_failure_reports_updated_and_not_updated_files -- --exact", + "result": "passed", + "summary": "Confirmed the existing test codifies a partial multi-file rename commit." + }, + { + "command": "adversarial CLI matrix against fixtures/ultimate-geography and temporary workspaces", + "result": "passed", + "summary": "Captured exit codes, stdout/stderr, overwrite behavior, stale export retention, media partial writes, JSON behavior, lock integrity, and workbench health." + } + ], + "validationOutput": [ + "UG main: verified 74 targets", + "UG Hardcore: verified 26 targets", + "UG de-extended: valid target with 319 notes and 546 media references", + "Imported/fmt-formatted round-trip deck validated successfully" + ], + "residualRisks": [ + "No external live Ultimate Geography checkout or real media tree was available; testing used the repository fixture that mirrors upstream.", + "Browser UI interaction and fault injection for every direct fs::write mutator were out of scope.", + "Workbench and media commands have demonstrated partial-write behavior that remains unresolved." + ], + "noStagedFiles": true, + "notes": "Review only; no product source or tests were edited. plan.md and progress.md were absent." +} +``` diff --git a/audit/08-workbench-backend.md b/audit/08-workbench-backend.md new file mode 100644 index 0000000..5e52b48 --- /dev/null +++ b/audit/08-workbench-backend.md @@ -0,0 +1,181 @@ +## Review + +### Scope and baseline + +- Reviewed ADR-0011, ADR-0014, ADR-0015, the Workbench server/API, Leptos staging/fetch paths, CLI API tests, browser E2E coverage, embedded assets, and the canonical-source constraints in `CONTEXT.md`. +- The requested `/home/jmo/Development/projects/brain-brew/plan.md` and `progress.md` do not exist in this working copy, so no plan/progress assumptions could be verified. + +### Correct + +- The production listener is explicitly loopback-only and defaults to an ephemeral port: `TcpListener::bind(127.0.0.1:port)` at `crates/brain-brew-cli/src/commands/workbench.rs:162-175`, matching ADR-0011's local-binding requirement. +- Browser code stages edits in `localStorage`; source files are mutated only by server endpoints. Stable keys include language/target/overlay, edit kind, path, and source at `crates/brain-brew-workbench-ui/src/staging.rs:41-80,130-139`. +- The list APIs enforce default/max bounds and reject invalid pagination at `crates/brain-brew-cli/src/commands/workbench.rs:1567-1608`. Real-server tests verify compact rows, full totals, pages, and invalid parameters at `crates/brain-brew-cli/tests/cli.rs:400-562`. +- In-process write operations are serialized by `apply_mutex` (`crates/brain-brew-cli/src/commands/workbench.rs:444-450,835-869,878-884`), and the focused concurrent API test passes (`crates/brain-brew-cli/tests/cli.rs:1220-1267`). +- Selection/detail response generations exist and the browser E2E suite tests a delayed stale language response (`crates/brain-brew-workbench-ui/src/lib.rs:3202-3239`; `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:49-70`). +- Embedded release assets are compiled into the CLI, carry hashed filenames/SRI, and the freshness script compares a clean release build byte-for-byte (`crates/brain-brew-cli/assets/workbench/index.html:1-25`; `scripts/check_workbench_ui_embed.sh:1-32`). The freshness check passed during this audit. +- ADR-0014 has substantial real-server API coverage and browser coverage, including real file mutation, staged edits surviving item unmount/refresh, lazy secondary pivots, and a real UG smoke path. This is a strong base, but it does not cover the failures below. + +### Blockers and ranked findings + +#### P0 — 1. A non-included source-field edit in a deck containing `!include` rewrites the whole Canonical Deck File into non-canonical YAML + +The source-edit path switches the entire deck to generic `serde_yaml::Value` handling whenever the raw file contains any `!include`, even when the edited field itself is not included (`crates/brain-brew-cli/src/commands/workbench.rs:4112-4126,4177-4199`). It then serializes the whole document with `serde_yaml::to_string` (`:4230-4238`) and validates only that generic YAML can be parsed (`:4528-4547`), not that it equals Brain Brew canonical YAML. + +A focused reproduction copied the real UG fixture, applied a source edit to `notes.note.abkhazia.fields.field.country`, and received HTTP 200 with `validation.ok=true`. The resulting `deck.yaml` changed list indentation and quoting throughout the file; `brainbrew verify --manifest ... --target en-standard` then failed with: + +```text +/tmp/brainbrew-audit-validation/deck.yaml is not in canonical format +``` + +This violates the source-of-truth/canonicalized-source requirement and ADR-0015's promise to write canonical YAML. The existing include test only checks that the include marker and values survive (`crates/brain-brew-cli/tests/cli.rs:1870-1912`); it never runs `fmt --check`/`verify` or uses the real UG include shape. + +#### P0 — 2. File fingerprints are informational only; stale browser drafts silently overwrite external edits + +ADR-0011 requires file fingerprints/polling and careful stale-file detection before Apply (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:35-36,47-50`). SHA-256 fingerprints are returned by `/api/workspace` (`crates/brain-brew-cli/src/commands/workbench.rs:601-618,4929-4952`), but `ApplyRequest` contains only selection plus edits and has no expected workspace generation/fingerprint (`:1636-1661`). Apply simply refreshes/read-merges current files and writes (`:878-930,1013-1035`). + +Verified reproduction: + +1. Loaded UG workspace/detail for German `Sukhumi: Sochumi`. +2. Externally changed the same overlay key to `Sukhumi: ExternalEdit`. +3. Posted the already-staged browser edit `Sukhumi: WorkbenchEdit`. +4. Server returned HTTP 200, `applied=true`, `validation.ok=true`; the external value was replaced by `WorkbenchEdit`. + +The existing stale test only rejects a request whose **source deck text** no longer matches (`crates/brain-brew-cli/tests/cli.rs:1571-1594`). It does not protect concurrent edits to the same translation key, manifest, include, or the preview-to-confirm window. The apply mutex protects only requests in this process, not editors or other Brain Brew processes. + +#### P1 — 3. Multi-file Apply is not all-or-nothing and has no rollback/cleanup recovery + +`write_files_atomically` prepares temporary files, then renames targets sequentially (`crates/brain-brew-cli/src/commands/workbench.rs:3878-3963`). If rename 2 fails, file 1 is already committed. The test explicitly accepts this partial state by asserting the error reports `updated files: deck.yaml` and remaining overlay files (`crates/brain-brew-cli/tests/cli.rs:1153-1184`). There is no backup/rollback, transaction journal, retry protocol, or cleanup of prepared temporary files on temp/rename/fsync errors. A failed new-language transaction can similarly leave overlay files without the manifest update, and retry then rejects the existing files (`crates/brain-brew-cli/src/commands/workbench.rs:835-869`). + +This can split a source edit from its stale-translation updates, leaving canonical sources semantically inconsistent. The helper is per-file atomic, not transaction-atomic; its name and prior task intent overstate the guarantee. + +#### P1 — 4. Confirm Apply does not require or bind to a successful preview + +ADR-0011 says Apply operations must show validation/preview before writing, and ADR-0015 says Preview and Confirm collect/show changes and only then write (`ADR-0011:50`; `ADR-0015:22`). The UI exposes Preview and Confirm as independent always-enabled buttons (`crates/brain-brew-workbench-ui/src/lib.rs:1629-1689`). Confirm posts the edits directly to `/apply`; the server reruns validation but accepts no preview token/hash (`crates/brain-brew-cli/src/commands/workbench.rs:338-359,878-1035`). + +The browser E2E suite actually codifies direct writes without preview for a new-language edit and a mixed source/target edit (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:627-646,1375-1428`). Even after a preview, there is no binding between the previewed edit set/file fingerprints and the confirmed request, so the affected files or draft may change before Confirm. + +#### P1 — 5. Apply collects only active or currently mounted language prefixes, not all staged edits + +ADR-0015 requires Preview/Confirm to collect from the central staged map, “not from only currently visible DOM nodes” (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:63-71`). The collector filters localStorage by a prefix list (`crates/brain-brew-workbench-ui/src/staging.rs:82-105`). That list contains the current selection plus prefixes discovered from `[data-storage-prefix]` DOM elements (`crates/brain-brew-workbench-ui/src/lib.rs:3895-3921`). + +Thus an edit staged in language A remains saved after switching to language B, but its unmounted prefix is omitted from Preview/Apply. Existing E2E covers navigation unmount within one prefix and a secondary pane that remains mounted; it does not cover staging in one language/target/overlay, switching away, and applying from another selection. + +#### P1 — 6. The lazy single-item migration is incomplete and makes items beyond the first 50 unreachable + +The server correctly returns bounded pages, but the UI never sends `limit`/`offset` and has no pagination controls. Notes explicitly render “pagination controls will load additional pages in the next slice” (`crates/brain-brew-workbench-ui/src/lib.rs:530-599`); card/source lists only show the first-page count (`:1013-1044,1287-1345`). On UG-sized decks, notes/cards/source strings after the default 50 cannot be selected, so the navigation model is not functionally complete. + +Only `note-detail` exists. ADR-0015 specifies card/source-string/metadata detail APIs (`ADR-0015:39-48`), but routes expose only list APIs plus full compatibility pivots (`crates/brain-brew-cli/src/commands/workbench.rs:191-212`). The UI loads a selected card/source through the full `card-pivot`/`source-string-pivot` endpoints and metadata through the full endpoint (`crates/brain-brew-workbench-ui/src/lib.rs:4073-4127,4151-4194`). + +The comparison endpoint is especially noncompliant: it returns all note details, all source-string groups, and all card summaries in one response (`crates/brain-brew-cli/src/commands/workbench.rs:771-812`), and the UI flattens every comparison note field (`crates/brain-brew-workbench-ui/src/lib.rs:1813-1840`). This contradicts selected-item-scoped multilingual context and can recreate the UG payload/DOM problem ADR-0015 rejected. + +#### P1 — 7. Loopback binding is correct, but the local file-writing server lacks defense in depth and path containment + +The router has no per-session capability, Host allowlist, Origin validation, or security middleware (`crates/brain-brew-cli/src/commands/workbench.rs:191-229`). This leaves DNS-rebinding/same-origin injection concerns for write endpoints. Card/deck HTML is rendered into `inner_html` (`crates/brain-brew-workbench-ui/src/lib.rs:1085-1091` and analogous note/source previews), while the embedded document has no CSP (`crates/brain-brew-cli/assets/workbench/index.html:1-25`); hostile event-handler markup from an untrusted workspace can execute with same-origin access to mutation APIs. + +Lexical traversal checks exist for media/new-language/include paths (`crates/brain-brew-cli/src/commands/workbench.rs:2105-2117,3817-3827,4511-4518`), but paths are joined and followed without canonicalizing the parent/target and proving it remains under an approved root. Symlinked directories can therefore escape the manifest/media root for reads or writes. New-language creation also has a check-to-rename race around `exists()` (`:844-868`). + +Threat-model clarification may change priority, but a local-only bind alone is not sufficient for a process that rewrites maintainer files. + +#### P2 — 8. Shutdown is abrupt and untested + +The server awaits `axum::serve` directly with no `with_graceful_shutdown`, signal handling, draining, or cleanup (`crates/brain-brew-cli/src/commands/workbench.rs:156-188`). Both API and browser harnesses terminate it with `Child::kill` (`crates/brain-brew-cli/tests/cli.rs:5575-5579`; `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:2483-2487`). A SIGINT/SIGTERM during sequential renames can therefore leave the partial states and temp files described above. There is no restart recovery test. + +#### P2 — 9. Active ADR/API contract drift is undocumented + +ADR-0011 remains Accepted and requires Iced/WASM plus a versioned API (`ADR-0011:17,28,35,48-49`). The actual separate UI crate is Leptos CSR (`crates/brain-brew-workbench-ui/Cargo.toml:1-24`; `documentation/docs/reference/workbench.md:13-29`), with no superseding ADR. The HTTP surface is unversioned, error responses are plain text tuples, and unknown `/api/...` paths fall through to the SPA index with HTTP 200 (`crates/brain-brew-cli/src/commands/workbench.rs:391-420`). This should be resolved by updating/superseding ADR-0011 and defining a versioned JSON success/error/404 contract, or by restoring the accepted architecture. + +### Missing E2E/API cases required before acceptance + +1. **Real UG source write:** edit a non-included note field in the real include-bearing UG fixture, then run `brainbrew verify`; assert only intended canonical files/content changed. +2. **Stale edit CAS:** stage a translation, externally change the same key/file, then Preview and Confirm; require a conflict response and zero writes. Repeat with a change between Preview and Confirm and with included scalar files. +3. **All staged scopes:** stage in language/target/overlay A, switch to B so A is unmounted, preview/confirm from B, and assert both scopes appear and write (or explicitly expose/select a workspace-wide draft set). +4. **Preview enforcement:** Confirm must be unavailable/rejected until a successful preview; changing a draft or any affected fingerprint must invalidate the preview. +5. **Failure recovery:** inject failure at every temp/fsync/rename index and SIGTERM during each phase; assert rollback, no orphan `.brainbrew-tmp-*`, and successful retry/restart. Include new-language manifest+overlay writes. +6. **Pagination usability:** navigate to and edit an item beyond row 50 for each list on the real UG fixture; assert bounded DOM and requests with advancing offsets. +7. **True detail contracts:** API and browser tests for `card-detail`, `source-string-detail`, and `metadata-detail`, plus a selected-item-only comparison payload and bounded comparison DOM. +8. **Path/security:** percent-encoded traversal, absolute/UNC paths, symlink escapes for media/include/new-language targets, hostile Host/Origin/DNS-rebinding simulation, and hostile preview markup under a restrictive CSP/sanitizer policy. +9. **Shutdown:** graceful SIGINT/SIGTERM while idle and during an Apply, with deterministic completion/abort semantics and no file corruption. +10. **API contract:** version field/path, JSON error envelopes/content types, unknown API route JSON 404, request size limits, and forward/backward contract tests. +11. **Release assets:** launch the server without `--dev-assets` in browser E2E and exercise a write flow from the embedded release bundle (the current freshness check proves byte freshness, not runtime behavior). + +### Notes + +- Focused tests passed, but the full browser E2E gate was not run in this review. Existing E2E breadth is useful; the primary gaps are conflict/failure/canonicality paths rather than happy-path UI wiring. +- No source files were edited. This report is the only file added by this audit agent. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only scope was preserved; only audit/08-workbench-backend.md was added, with no product-source edits." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings cite exact ADR/code/test locations and include two focused real-server reproductions plus focused test and asset-validation results." + } + ], + "changedFiles": [ + "audit/08-workbench-backend.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell cargo test -p brainbrew --test cli workbench_navigation_lists_are_paginated_and_compact -- --exact", + "result": "passed", + "summary": "1 passed; compact pagination/list-detail API baseline is green." + }, + { + "command": "devenv shell cargo test -p brainbrew --test cli workbench_apply_temp_write_failure_leaves_targets_unchanged -- --exact", + "result": "passed", + "summary": "1 passed; pre-rename temp failure leaves targets unchanged (cleanup is not asserted)." + }, + { + "command": "devenv shell cargo test -p brainbrew --test cli workbench_concurrent_apply_requests_are_serialized -- --exact", + "result": "passed", + "summary": "1 passed; in-process Apply transactions do not overlap." + }, + { + "command": "devenv shell cargo test -p brainbrew --test cli workbench_source_edits_can_migrate_keys_change_all_and_preserve_includes -- --exact", + "result": "passed", + "summary": "1 passed; existing small-fixture include assertions pass, but they do not check canonical formatting." + }, + { + "command": "devenv shell ./scripts/check_workbench_ui_embed.sh", + "result": "passed", + "summary": "Release Leptos/WASM build matched checked-in embedded assets exactly." + }, + { + "command": "temporary UG server: GET workspace/detail, externally replace German Sukhumi translation, POST the staged old edit to /api/workbench/apply", + "result": "failed", + "summary": "Audit validation exposed a defect: HTTP 200/applied=true silently replaced ExternalEdit with WorkbenchEdit." + }, + { + "command": "temporary UG server: POST source edit for note.abkhazia, then brainbrew verify --manifest /brainbrew.yaml --target en-standard", + "result": "failed", + "summary": "Apply reported validation.ok=true, but verify failed because deck.yaml was no longer canonical after generic serde_yaml serialization." + }, + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Before writing this report, product working copy had no source changes from this agent; other audit report files from concurrent reviewers were present." + } + ], + "validationOutput": [ + "Focused Rust tests: 4 passed, 0 failed.", + "Embedded asset freshness: Workbench embedded release assets are fresh.", + "Stale-write reproduction: http_status=200, applied=True, validation={'errors': [], 'ok': True}; resulting YAML contained Sukhumi: WorkbenchEdit instead of the external edit.", + "UG canonicality reproduction: Apply validation.ok=true; subsequent verify exited 1 with '/deck.yaml is not in canonical format'." + ], + "residualRisks": [ + "Full devenv shell e2e was not run; browser findings were established by code/test inspection.", + "plan.md and progress.md requested by the task were absent, so their intended constraints could not be audited.", + "Security priority depends on the project's untrusted-workspace and DNS-rebinding threat model, which is not documented.", + "Cross-platform rename behavior, especially replacement on Windows, was not exercised." + ], + "noStagedFiles": true, + "notes": "Review gate should fail until at least the two P0 findings are fixed and covered by real-server/browser tests. Jujutsu is used, so there is no Git staging area; no commit/staging operation was performed." +} +``` diff --git a/audit/09-workbench-ui-e2e.md b/audit/09-workbench-ui-e2e.md new file mode 100644 index 0000000..a8a9224 --- /dev/null +++ b/audit/09-workbench-ui-e2e.md @@ -0,0 +1,150 @@ +## Review + +### Scope and user-flow map + +- The requested `plan.md` and `progress.md` were absent, so no plan/progress assumptions could be verified. +- Current UI startup launches `/api/workspace` and the first `/api/workbench/note-list` request in parallel, then auto-loads the first note detail (`crates/brain-brew-workbench-ui/src/lib.rs:106-178,2579-2597`). +- Notes are the default view. Cards, Source Strings, and Metadata are lazy-loaded when their view buttons are opened (`crates/brain-brew-workbench-ui/src/lib.rs:197-289,2942-2970`). +- Note/Card/Source String edits are staged immediately on input into `localStorage`, keyed by language, target, overlay, kind, path, and original source (`crates/brain-brew-workbench-ui/src/staging.rs:41-80,130-139`). Refresh and selected-item navigation restore those drafts. +- Apply Preview and Confirm Apply are global controls below every view. New-language scaffolding and an optional comparison-language pane are also global workflow panels (`crates/brain-brew-workbench-ui/src/lib.rs:290-293,1613-1694`). + +### Correct + +- The happy paths are broad and currently green: the browser suite exercises real WASM UI startup, visible inline editing with caret preservation, refresh persistence, file mutation, source edits, source strings, metadata, multi-pane writes, lazy views, stale language-list responses, real Ultimate Geography navigation, and media (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:28-343`). `devenv shell e2e` passed all 13 tests. +- Selection and selected-detail requests have generation guards, and the delayed-language E2E test verifies a late list response cannot replace the latest selection (`crates/brain-brew-workbench-ui/src/lib.rs:3096-3175`; `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:402-493`). +- Note/Card details are single-item mounted, advanced controls are collapsed, secondary pivots are lazy, and the real UG E2E enforces bounded initial DOM counts (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:800-932,1791-1860,2297-2335`). +- Failure artifacts include a screenshot, DOM, server logs, and structured browser/media/caret diagnostics (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:2490-2596`). Polling helpers have replaced fixed sleeps for user assertions (`:1862-1957`). +- Embedded assets have hashed JS/WASM/CSS names and SRI. The release rebuild matched the checked-in assets byte-for-byte via `scripts/check_workbench_ui_embed.sh:1-32`, and that check is in CI (`devenv.nix:47-52,108-115`; `.github/workflows/ci.yml:25-33`). + +### Severity-ranked findings + +#### P0 — Blocker: stale browser drafts have no file-conflict protection and can overwrite external YAML edits + +`/api/workspace` returns fingerprints, but the UI reduces them to a count and discards their values (`crates/brain-brew-workbench-ui/src/lib.rs:2518-2538`). It does not poll again. Preview/Apply requests contain only selection plus edits, with no expected fingerprints or workspace generation (`:1629-1658`). This is the exact hazard ADR-0011 calls out for browser-local drafts (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:35-36,47-50`): a maintainer can stage an edit, change the same overlay in an editor or another process, and later Confirm Apply without any warning. + +**Concrete tests to add:** browser E2E that stages a translation, externally changes the same YAML key, then requires Preview and Confirm to show a conflict and leave the external bytes intact; repeat with a file change between Preview and Confirm. Add API CAS tests for overlay, deck, manifest, and included-scalar fingerprints. + +#### P1 — Blocker: deck-wide progress is replaced with selected-note-only counts + +The list response supplies deck-wide totals and initially renders them (`crates/brain-brew-workbench-ui/src/lib.rs:397-427`). As soon as a note detail mounts, `decorate_note_detail_dom` calls `refresh_progress_from_dom` (`:2275-2287`), which counts only the currently mounted detail rows and overwrites complete/missing/total while retaining the old deck-wide stale count (`:3743-3805`). On UG this can display a handful of fields as the whole deck, potentially with `stale > total`. The real-UG E2E checks only that the phrase “Main note-field progress” exists, not its values (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:807-810`). This contradicts ADR-0015’s deck-wide-total requirement (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:52-57`). + +**Concrete test to add:** on the real UG fixture, capture `/note-list` progress, wait for first detail, navigate and stage one field, and assert the visible/data-attribute totals remain deck-wide while only the relevant aggregate changes. + +#### P1 — Blocker: items after the first 50 are unreachable + +The server defaults lists to 50 rows, but UI fetches never send `limit` or `offset`. Notes explicitly show a placeholder saying controls are for “the next slice” (`crates/brain-brew-workbench-ui/src/lib.rs:592-596`); Cards and Source Strings only report `Showing rows.len() of total` and provide no page/load-more control (`:1013-1044,1315-1343`). The real UG test only asserts row count is at most 50 (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:889-896,916-923`), so it validates boundedness while missing reachability. This leaves most UG items uneditable and does not fulfill ADR-0015’s paginated-navigation decision (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:17,50-59`). + +**Concrete tests to add:** use the UG fixture to navigate to and edit note/card/source-string item 51+ through visible controls; assert advancing `offset` requests and the DOM budget after every page. Add metadata navigation/detail coverage too. + +#### P1 — Blocker: Confirm Apply bypasses Preview, and existing E2E codifies the bypass + +Preview and Confirm are independent, always-enabled buttons; Confirm posts directly to `/apply` regardless of preview state (`crates/brain-brew-workbench-ui/src/lib.rs:1629-1689`). This violates ADR-0011’s required validation/preview step and ADR-0015’s preview-then-confirm flow (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:50`; `documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:22`). The new-language edit and mixed source/target browser tests deliberately click Confirm without Preview (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:627-646,1408-1428`). Draft changes, selection changes, or file changes also do not invalidate a prior preview. + +**Concrete tests to add:** assert Confirm is disabled until a successful preview; mutate a draft after preview and assert Confirm is disabled again; change a watched file after preview and require re-preview/conflict; double-click Preview/Confirm and assert only one request/write occurs. + +#### P1 — Staged edits from unmounted scopes disappear from Apply + +ADR-0015 requires Apply to collect from the central staged map rather than mounted DOM (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:63-71`). Instead, collection filters localStorage by the current prefix plus prefixes discovered from currently mounted `[data-storage-prefix]` elements (`crates/brain-brew-workbench-ui/src/lib.rs:3895-3921`; `crates/brain-brew-workbench-ui/src/staging.rs:82-105`). Stage in language A, switch to B, and A remains locally saved but is absent from B’s count, Preview, and Apply unless an A comparison pane happens to remain mounted. Current E2E covers item unmount within one scope and mounted secondary panes, not unmounted language/target/overlay scopes. + +**Concrete test to add:** stage in A, switch to B so A is absent from the DOM, stage in B, then preview from B and assert both scopes/files appear and are applied—or explicitly design a draft manager that exposes and selects scopes. + +#### P1 — Apply completion can reset the wrong selection and always ejects users to Notes + +Every successful note-list publication forces `WorkbenchView::Notes` and clears all secondary view states (`crates/brain-brew-workbench-ui/src/lib.rs:2579-2608`). The post-Apply refresh therefore ejects a user from Card, Source String, or Metadata after their edit. More seriously, Apply does not capture a selection generation before posting. Its refresh captures the generation only after `/apply` returns (`:1665-1671,4018-4034`), so if a user changes language while Apply is pending, the old pivot refresh can be treated as current and switch the UI back to the old language. Existing E2E stops after seeing “Applied” and file bytes; it does not wait for and assert the settled view/selection (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:1240-1260`). + +**Concrete tests to add:** delay `/apply`, switch language/view while it is pending, release it, and assert the latest language/view/selected item remain; after a normal Card/Source String/Metadata apply, wait for all refresh requests and assert the originating view and selected item remain active. + +#### P1 — The comparison and Metadata workflows still violate selected-item scope + +The comparison UI calls the legacy full `/comparison-pane`, hardcodes overlay `base`, and reuses the active target label (`crates/brain-brew-workbench-ui/src/lib.rs:1709-1732`). It then flattens every returned note field and renders every source-string/card summary (`:1813-1840,1871-1961`), recreating the all-items payload/DOM that ADR-0015 rejected. Metadata likewise calls the full `/metadata` endpoint and renders all editable items in one table (`:1524-1561,4094-4102`) even though the server already exposes `/metadata-list`. This is neither selected-item-scoped nor usable for target languages with different target labels/overlay groups. + +**Concrete tests to add:** on UG, load a comparison pane and assert payload/DOM contain only the selected item and stay under a fixed row/media budget; select a non-`base` overlay and a language with a different target map and assert correct scope. Add `metadata-list` pagination plus one `metadata-detail` editor test. + +#### P2 — Error/recovery and draft-management UX is incomplete + +There is no Discard edit/Discard all control; `clear_prefix` is only called after Apply (`crates/brain-brew-workbench-ui/src/staging.rs:116-128`; `crates/brain-brew-workbench-ui/src/lib.rs:1665-1668`). A bad persistent draft therefore has no explicit UI recovery path. Async buttons are never disabled while requests run, and `post_json` does not inspect HTTP status or preserve plain-text API errors before trying JSON decoding (`:3873-3882`), so duplicate requests are possible and useful server validation messages can collapse into decode errors. New-language Create is enabled before Preview and changing Code/Template after Preview does not invalidate the preview (`:2000-2047,2050-2078`). + +**Concrete tests to add:** invalid/duplicate new language with exact actionable error text and no writes; discard one/all drafts across refresh; failed Apply retains drafts; buttons expose busy/disabled state; changing scaffold inputs invalidates preview; repeated clicks issue one request. + +#### P2 — Accessibility coverage and semantics are insufficient for a file-editing tool + +Positive basics exist: visible focus outlines (`crates/brain-brew-workbench-ui/static/workbench.css:87-94`), labeled global selects, a labeled view nav, and inline preview fields receive textbox roles/labels. However: + +- The persistent page heading is always “Deck Workbench Note pivot” even in other views (`crates/brain-brew-workbench-ui/src/lib.rs:366-369`). +- Advanced editor inputs/selects and Metadata/comparison inputs have no programmatic per-field labels; tables have no captions and their `` cells omit `scope` (`:667-688,1095,1553-1560,1871-1918`). +- Apply output and new-language status are not live regions/status roles, and busy buttons expose neither `disabled` nor `aria-busy` (`:1685-1693,2070-2078`). +- Active Note/Card/Source String rows are visual-only; there is no `aria-current`/`aria-selected` state (`:562-586,1024-1036,1326-1335`). +- Browser tests use JavaScript to focus one inline field but do not test a keyboard-only tab/order/activation flow or accessible names (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:1791-1860`). + +**Concrete tests to add:** automated accessibility scan on each loaded view plus semantic assertions for unique heading, labels/table headers, live apply status, active-row state, and busy state; a keyboard-only E2E that selects a work item, edits, previews, and confirms without script-forced focus or mouse clicks. + +#### P2 — E2E remains happy-path biased and does not run the embedded release bundle + +The suite is strong on happy paths but only one test checks severe browser logs, and no test asserts failed network responses, malformed JSON, server exit during startup, external-file conflicts, preview invalidation, Apply failure retaining drafts, pagination reachability, or accessibility. The E2E script always passes debug Trunk output through `--dev-assets` (`devenv.nix:60-67`); CLI tests verify embedded JS/WASM HTTP bytes/content types, and the freshness check proves byte equality, but no browser write flow boots the release-embedded bundle. The harness README is also stale: it still says Iced and claims full UG coverage is future work even though that test exists (`crates/brain-brew-workbench-e2e/README.md:9-14`). + +**Concrete tests to add:** parameterize one complete edit/preview/confirm smoke to run without `BRAINBREW_E2E_DEV_ASSETS`; check severe console/network errors in every test teardown; add the failure/race tests listed above. Add a bounded timeout while waiting for the server’s first stdout line (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:2452-2470`). + +#### P2 — Accepted ADR and documentation drift after the Leptos migration + +ADR-0011 remains Accepted and explicitly chooses Iced/WASM (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:1-19,28-35`), while the current crate and user documentation use Leptos CSR (`crates/brain-brew-workbench-ui/Cargo.toml:9,19-24`; `documentation/docs/reference/workbench.md:13-31`). The E2E README still says Iced (`crates/brain-brew-workbench-e2e/README.md:9`). The reference page also describes the old `note-pivot` API even though the UI uses `note-list` + `note-detail` (`documentation/docs/reference/workbench.md:50-56`; `crates/brain-brew-workbench-ui/src/lib.rs:4037-4062`). Supersede/update the ADR and synchronize docs; do not leave architecture rationale only in archived task records. + +### Review gate + +**Fail.** The browser suite is green and the embedded asset pipeline is fresh, but stale-file overwrite protection, incorrect progress, unreachable items, Preview bypass, incomplete staged-scope collection, and Apply selection races prevent treating the current UI as a safe complete real-user workflow. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only scope was preserved; only audit/09-workbench-ui-e2e.md was added and no product source or tests were edited." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "The report maps the user states, cites exact UI/E2E/ADR locations, records focused validation commands, ranks verified findings, and specifies concrete browser/API tests for each major gap." + } + ], + "changedFiles": [ + "audit/09-workbench-ui-e2e.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "jj status && jj diff --stat", + "result": "passed", + "summary": "Inspected the Jujutsu working copy; only audit reports from concurrent review work were present, with no product-source changes from this reviewer." + }, + { + "command": "devenv shell cargo test -p brain-brew-workbench-ui", + "result": "passed", + "summary": "1 integration test passed; the host-side UI crate currently has no behavior tests beyond WorkspaceSummary." + }, + { + "command": "devenv shell e2e", + "result": "passed", + "summary": "All 13 real Chromium/ChromeDriver Workbench tests passed in 22.02s." + }, + { + "command": "devenv shell workbench-ui-embed-check", + "result": "passed", + "summary": "Release Trunk output matched checked-in embedded JS/WASM/CSS/index assets exactly." + } + ], + "validationOutput": [ + "UI tests: 1 passed, 0 failed.", + "Browser E2E: 13 passed, 0 failed, finished in 22.02s.", + "Embedded asset check: Workbench embedded release assets are fresh.", + "Review gate: failed because verified workflow/ADR gaps remain despite green happy-path tests." + ], + "residualRisks": [ + "No product code was changed, so all ranked findings remain open.", + "The requested plan.md and progress.md were absent and could not be audited.", + "No automated accessibility scanner was installed; accessibility findings are based on DOM semantics and existing test inspection.", + "The E2E run exercised dev assets, not a browser session booted from the embedded release bundle." + ], + "noStagedFiles": true, + "notes": "Jujutsu has no Git staging area and no staging/commit operation was performed. Other audit markdown files were concurrently present in the working copy; this reviewer added only audit/09-workbench-ui-e2e.md." +} +``` diff --git a/audit/10-tests.md b/audit/10-tests.md new file mode 100644 index 0000000..2c32da3 --- /dev/null +++ b/audit/10-tests.md @@ -0,0 +1,175 @@ +# Test-suite false-confidence audit + +## Review + +### Correct — what the suite genuinely proves + +- The maintained default gate is broad: test listing found **404 non-browser tests** (79 core, 159 formats, 165 CLI, 1 native UI), plus **13 WebDriver tests**. There are **0 ignored tests**, **0 `.snap` files**, and no property/fuzz framework in the workspace. +- Core composition has substantial example coverage: overlay ordering/conflicts, add/merge/replace/remove/override families, translation dictionaries, stale records, target adaptations, structured messages/images, and translation coverage are exercised in `crates/brain-brew-core/tests/overlay_compose.rs` (55 tests). Canonical validation and content validation add 12 tests, but their negative matrix is incomplete as detailed below. +- Format coverage is strongest around deterministic positive behavior and fail-closed CrowdAnki fields: canonical/overlay/manifest/lock/media codecs, includes, hostile scalar corpora, import rejection cases, and one independent inline CrowdAnki JSON expectation are covered. In particular, `crates/brain-brew-formats/tests/crowdanki.rs:10-22` compares export against a hand-authored JSON value rather than only round-tripping through Brain Brew. +- CLI tests invoke the real binary for all principal commands: `fmt`, `validate`, `compose`, `diff`, `explain`, `targets`, `export`, `import`, `verify`, `lock`, `media`, `translations`, and `workbench`. `crates/brain-brew-cli/tests/cli_contract.rs:67-376` adds explicit stdout/stderr/exit contracts for validate, explain, diff, targets, help, version, and argument errors. +- Workbench coverage is real rather than mocked: 32 named server/API integration tests in `crates/brain-brew-cli/tests/cli.rs` and 13 browser scenarios in `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:26-343`. `devenv shell e2e` passed all 13 against Chromium in 16.57s. +- UG fixtures exercise all 74 main targets and all 26 Hardcore companion targets for composition/validation (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:142-181,335-410`), enforce the variable-first/shared-extension source shape (`:412-533`), and cover real-manifest/media browser smoke paths (`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:276-320`). These are useful fixture and propagation checks; they are not an independent parity oracle. + +### Coverage map + +| Surface | Tested flows | Materially untested or weakly tested | +|---|---|---| +| Domain | Stable path parsing/globs; basic deck invariants; most overlay intents; conflict detection; translations/no-change/stale/adaptations; structured image lowering; semantic raw-field/note/tombstone examples | Semantic equality of every canonical property; structured-field overwrite guards; entity-level expected-base comparison; structured-message dependency chains/cycles; cross-kind tombstones; several validation error kinds; general algebraic laws | +| Formats | Canonical/overlay/manifest/lock/media parse/emit/idempotence; include resolution; unknown fields; fixed hostile strings; CrowdAnki import/export and many unsupported fields | Duplicate keys in dynamic maps; malformed mutually exclusive overlay payloads; arbitrary Unicode/control/key generation; parser panic resistance; independent full-format parity; several path-rich diagnostics | +| CLI | Real-binary happy/error flows for every principal command; output contracts for four JSON-capable commands; local path/file-tarball locks; media and translation workflows | Remote Git/GitHub lock end-to-end behavior; crash/interruption recovery; broad concurrent invocation/stress; platform-specific path/process behavior; exact contracts for many large human outputs | +| Workbench | Real loopback APIs, filesystem writes, atomic rename failure handling, two-request serialization; browser staging/refresh/apply, pivots, metadata, multi-pane edits, scaffold, UG load/media | Stale external-file/session rejection; browser-visible invalid preview/apply failure; localStorage corruption/quota/unavailable cases; deterministic unit tests for staging key/collection/clear logic; concurrent conflicting writers beyond two-request serialization | +| Ultimate Geography | Canonical source shape; 74 + 26 target composition; selected resolved content; mutation propagation through exporter; real UI smoke | Required CI release oracle; exact old-tool parity; Experimental/Hardcore release parity; full adapter defaults/config/model metadata; media bytes/hashes against release; independent target-by-target goldens | + +## Prioritized findings and proposed regression tests + +### Blocker — “strict YAML” duplicate coverage tests only struct fields, not dynamic maps + +The only generic duplicate-key assertion is `base` repeated at `crates/brain-brew-formats/tests/manifest_yaml.rs:724-734`. That exercises a duplicate field on a serde struct. Most maintainer content is instead decoded into `BTreeMap` values (for example canonical entity maps in `crates/brain-brew-formats/src/canonical_yaml.rs:2316-2325`, manifest maps at `crates/brain-brew-formats/src/manifest.rs:624-639`, lock packages at `crates/brain-brew-formats/src/lockfile.rs:148-154`, and media-map roots at `crates/brain-brew-formats/src/media_map.rs:13-21`). There is no test for duplicate entity IDs, note fields, overlay changes/translations, targets, languages, packages, or media IDs. The positive canonical/idempotence suite therefore gives false confidence that all YAML source is strict: later dynamic-map values can overwrite earlier source without the existing test noticing. + +**Highest-value regression:** a table-driven `rejects_duplicate_dynamic_map_keys` that feeds duplicate keys at every map-bearing schema level through both parse and `fmt`, asserts a nonzero CLI exit, asserts the original file is byte-unchanged, and checks a diagnostic containing the duplicate key and schema/file location. Include duplicate keys introduced by `!include`, not only inline YAML. + +### Blocker — semantic round-trip oracles delegate to a demonstrably incomplete `semantic_diff` + +`CanonicalDeck::semantic_diff` compares name, description, variables, note types, notes, media, and tombstones but omits deck ID and deck-level adapter IDs (`crates/brain-brew-core/src/compose.rs:45-67`). Note comparison examines raw `fields`, tags, variables, note-type ID, and adapter IDs, but never `field_messages` or `field_images` (`:1972-2014`, especially `:2001`). The dedicated tests cover only unchanged decks, one raw field, note add/remove, and a tombstone (`crates/brain-brew-core/tests/semantic_diff.rs:8-61`). + +This weak oracle is reused by `import_export_round_trip_is_semantically_equal_when_suggested_ids_match_source` (`crates/brain-brew-formats/tests/crowdanki.rs:25-34`) and by the test explicitly named `structured_image_fields_survive_crowdanki_export_import_round_trip` (`:162-205`). Those tests can pass after loss/change of precisely the structured representation and deck adapter identity they claim to protect. + +**Highest-value regressions:** + +1. A mutation table that changes exactly one canonical property at a time—deck ID, every adapter-ID scope, note type field/template details, structured-message format/component/reference, structured-image media ID, tags, media, and tombstones—and asserts a non-empty diff with an exact stable path. +2. A property test over generated valid decks: `a == b` iff `a.semantic_diff(&b).is_empty()`; also assert symmetry of emptiness. +3. CrowdAnki round-trip tests must directly compare all expected canonical fields/adapter IDs (or compare `CanonicalDeck` after explicitly documented normalization), rather than relying solely on semantic diff. + +### High — the 55 composition examples miss the dangerous representation and graph cases + +The large count obscures several high-risk holes: + +- Field blank/expected-base logic reads only `Note.fields` (`crates/brain-brew-core/src/compose.rs:1124-1172`), while applying a raw/message/image payload deletes the other representations (`:1173-1205`). `field_level_merge_rejects_non_blank_for_structured_messages_and_images` tests a **structured incoming payload over a nonblank raw base** (`crates/brain-brew-core/tests/overlay_compose.rs:2268-2301`); it does not test add/merge over an already-populated structured-image/message base whose raw placeholder is blank. +- Entity-level destructive checks can stop at `expected_base.is_some()` (`crates/brain-brew-core/src/compose.rs:1619-1633`), but no stale/wrong-value tests exist for complete card-template, field-definition, note-type, or note replacement. +- Structured messages resolve all references against one cloned snapshot (`crates/brain-brew-core/src/messages.rs:133-159`), but tests cover direct successful/error substitutions, not multi-hop dependencies, order independence, self-reference, or cycles. +- Validation implements mismatched entity IDs and duplicate field/template detection (`crates/brain-brew-core/src/validate.rs:56-91`), while `crates/brain-brew-core/tests/canonical_deck_validation.rs:55-217` does not exercise those error kinds. Unknown structured media is tested at render/verify, not as a base validation/compose invariant. + +**Highest-value regressions:** add/merge/field-fill over existing structured images and messages must fail without a representation-aware expected base; wrong entity expected values must fail after simulated upstream edits; message chains must resolve topologically and cycles must produce path-rich errors; one table should mutate a valid deck to trigger every `ValidationErrorKind` with exact paths. Add cross-kind same-ID tombstone and remove-history cases. + +### High — Workbench has no stale external-file/session test, despite the governing contract + +ADR-011 requires file fingerprints/polling for stale-session detection and validation before writes (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:45-50`). ADR-014 requires browser tests to exercise staged local edits and Apply preview/validation (`documentation/docs/reference/decisions/0014-require-workbench-api-and-browser-e2e-tests.md:13-17`). Workspace fingerprints are displayed/tested, but `ApplyRequest` carries only language, target, overlay, and edits (`crates/brain-brew-cli/src/commands/workbench.rs:1636-1660`); apply reloads current context (`:878-890`) without an expected workspace fingerprint. Existing concurrency coverage proves mutex serialization of two requests (`crates/brain-brew-cli/tests/cli.rs:1221-1267`), not stale-draft conflict rejection. + +A browser can stage a draft, another process can edit the same overlay, and the suite has no test requiring Apply to reject rather than overwrite/rebase unexpectedly. + +**Highest-value regression:** stage a translation in the browser, mutate the overlay externally after staging, then click preview and Apply. Assert visible stale-session diagnostics, `data-validation-ok=false`, no write, preservation of the external bytes, and retention of the browser draft. Mirror it at API level with expected fingerprints, including an unrelated-file edit and a conflicting same-path edit so the intended policy is explicit. + +### High — UG “parity” is skipped as a passing test in normal CI and compares only a subset when present + +`ultimate_geography_fixture_exports_match_release_oracle_semantics_when_available` returns success when the oracle is absent (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:536-547`). The audited checkout has no oracle, and CI only runs `devenv shell ci` without fetching one (`.github/workflows/ci.yml:10-26`). Thus the default run reports this test as `ok`, not ignored/skipped, while performing no parity comparison. + +When an oracle is supplied, the custom comparator checks selected deck/model/note values (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:1798-1855`), omitting much of CrowdAnki JSON such as deck configuration/defaults, field attributes, template metadata beyond name/qfmt/afmt, note data/flags, and any additional note model. It also filters to standard/extended targets (`:551-565`), excluding experimental and Hardcore release flows. + +The all-target mutation tests are metamorphic propagation checks, not independent parity: they export a Brain Brew baseline and a Brain Brew-mutated deck with the same exporter and compare their path delta (`:1468-1511`). A shared baseline/export bug survives both sides. + +**Highest-value regression:** commit immutable old-tool/release `deck.json` goldens for a bounded representative matrix (source/RTL/CJK languages × standard/extended, plus Hardcore where a release oracle exists), configure manifest golden checks, and make their presence mandatory in CI. Use `compare_deck_json_values` with narrow documented allowlists and assert exact unexpected-path emptiness. Keep all-target identity/count/hash invariants separately; label mutation tests “propagation” rather than parity. + +### High — malformed overlay union branches have no negative matrix and can silently discard supplied data + +`FieldChangeYaml` independently accepts scalar `value`, positional `message`, formatted `format`, and `variables`; formatted input takes precedence over positional message, while scalar value remains independently populated (`crates/brain-brew-formats/src/canonical_yaml.rs:2176-2225`). `MediaChangeYaml` turns every partial `(path, sha256)` pair into `media: None` (`:2249-2275`). The tail of the overlay tests covers valid media/remove and one unknown top-level field only (`crates/brain-brew-formats/tests/overlay_yaml.rs:850-883`), not conflicting/missing payload alternatives. Format/parse “success” can therefore normalize away author intent. + +**Highest-value regression:** exhaustively reject each conflicting pair and all-three field payloads, `variables` without `format`, remove-with-payload, zero-payload non-remove, and media with only path or only hash. Exercise `overlay_from_str`, `overlay_format_str`, and CLI `fmt`, asserting path-specific errors and unchanged files. + +### Medium — no property, fuzz, panic-resistance, or mutation testing exists + +The format crate has only serde/JSON/YAML/hash dependencies (`crates/brain-brew-formats/Cargo.toml:12-17`). Adversarial coverage is a fixed corpus of 7 hostile values/6 keys (`crates/brain-brew-formats/tests/emitter_roundtrip.rs:17-34`) plus 20 hostile strings (`crates/brain-brew-formats/tests/yaml_scalar_adversarial.rs:17-41`). This is good regression coverage but cannot explore combinations of Unicode, controls, block chomping, map-key grammar, nested includes, or arbitrary malformed JSON/YAML. The custom DeckPath/glob/content/media parsers likewise have only handpicked tables. No fuzz target or generated law test was found. + +**Highest-value additions:** + +- `proptest` valid model generators for YAML/overlay/manifest/lock/media round-trip and formatter idempotence. +- Generated DeckPath display/parse round-trip and glob matcher comparison against a small independent reference implementation. +- Fuzz targets for canonical/overlay YAML parse+format, CrowdAnki import/comparator, media HTML/CSS reference extraction, and content validation with the invariant “never panic/hang.” +- Mutation testing on semantic diff, expected-base checks, and fail-closed adapter fields to measure whether assertions kill omitted-branch mutants. + +### Medium — UG propagation dominates the default suite and duplicates expensive baselines + +The full default run passed 404 tests in **189.075s**; `ultimate_geography_fixture` alone took **162.27s**, with five tests reported as running over 60 seconds. The helper recomposes and reexports every target baseline for every mutation case (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:1468-1486`). The seven mutation groups contain 26 helper calls, so the 74-target fixture repeatedly performs roughly 1,924 baseline/mutation pairs; identical baselines are recomputed across cases and parallel tests. + +This makes the suite slower without adding independent oracle strength and encourages developers to run narrower commands. + +**Highest-value restructure:** cache each target’s composed deck/export once per test executable; run each isolated mutation against a representative target matrix, then use one combined overlay or invariant scan for all 74 targets. Keep a separately named exhaustive scheduled/CI tier if full combinatorics are required. Preserve the fast all-target compose/validate test and mandatory independent parity goldens. + +### Medium — browser staging behavior has almost no deterministic unit coverage + +The UI crate has one native test, only for `WorkspaceSummary` (`crates/brain-brew-workbench-ui/tests/workspace_summary.rs:1-17`), while the localStorage staging implementation is WASM-only (`crates/brain-brew-workbench-ui/src/staging.rs:1-163`). Browser happy paths cover persistence and clearing indirectly, but there are no focused tests for key delimiter collisions, prefix isolation, malformed stored JSON, deterministic edit ordering, unavailable storage, set/remove errors, or quota failures. `collect_staged_edits_for_prefixes` silently skips malformed entries and uses browser storage enumeration order (`:82-105`), yet Apply ordering and diagnostics are not specified by tests. + +**Highest-value regression:** extract pure key/edit selection and ordering logic for native unit tests; add `wasm-bindgen-test` cases with a storage facade for malformed/unavailable/quota scenarios; add one browser negative test showing an invalid preview and ensuring Confirm Apply cannot mutate files. + +### Note — no brittle snapshot/ignored-test problem was found, but “golden” strength varies + +There are no Rust snapshot files or ignored tests. Exact canonical string assertions and the inline `EXPECTED_CROWDANKI_JSON` are intentional format contracts, not incidental UI snapshots. The main weakness is the opposite: too few independent checked-in goldens, optional parity silently reported as success, and several round-trip/metamorphic assertions that share implementation logic. + +### Residual risks + +- No line/branch coverage tool is installed in the environment, so this is a test-list plus source-to-test mapping audit rather than instrumented coverage. +- No mutation campaign or fuzz run was performed; absence was established from workspace manifests/source and repository layout. +- Remote Git/GitHub locking, non-Linux platforms, process interruption, and filesystem permission/race matrices remain largely outside hermetic CI coverage. +- `plan.md` and `progress.md` were requested inputs but do not exist at the supplied paths; the audit used project guidance, ADRs, source, tests, fixtures, and CI definitions instead. +- Review-only constraints prevented adding the proposed regression tests or fixing the exposed implementation gaps. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "The review maps domain, format, CLI, Workbench, and Ultimate Geography coverage and reports prioritized Blocker/High/Medium gaps with exact file:line references and concrete regression-test proposals." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "cargo test --workspace --exclude brain-brew-workbench-e2e --all-targets -- --list", + "result": "passed", + "summary": "Listed 404 default-gate tests across core, formats, CLI, and UI." + }, + { + "command": "cargo test --workspace --exclude brain-brew-workbench-e2e --all-targets", + "result": "passed", + "summary": "404 tests passed in 189.075s; UG fixture took 162.27s and its absent-oracle parity test returned success without comparing." + }, + { + "command": "cargo test -p brain-brew-workbench-e2e -- --list", + "result": "passed", + "summary": "Listed 13 browser E2E scenarios." + }, + { + "command": "devenv shell e2e", + "result": "passed", + "summary": "Built the WASM UI, launched Chromium/chromedriver, and passed all 13 browser tests in 16.57s." + }, + { + "command": "repository scans for #[ignore], .snap, and property/fuzz frameworks", + "result": "passed", + "summary": "Found 0 ignored tests, 0 snapshot files, and no proptest/quickcheck/fuzz/loom framework usage." + }, + { + "command": "jj status && jj diff --stat", + "result": "passed", + "summary": "Confirmed the Jujutsu working copy already contained other requested audit artifacts; no source/test edits were made." + } + ], + "validationOutput": [ + "default test listing: 404 tests", + "default suite: 404 passed, 0 failed, 0 ignored", + "Workbench browser suite: 13 passed, 0 failed", + "Ultimate Geography release oracle: absent; parity body skipped via early return", + "ignored tests: 0; snapshot files: 0; property/fuzz frameworks: 0" + ], + "residualRisks": [ + "No instrumented line/branch coverage or mutation score was available.", + "The external UG release oracle was absent, so actual release parity was not validated.", + "No proposed regression tests were added because this assignment was review-only.", + "plan.md and progress.md were absent at the requested paths." + ], + "noStagedFiles": true, + "notes": "Only the requested audit report was written; changedFiles/testsAddedOrUpdated remain empty because no product or test source was modified. Jujutsu has no staging area." +} +``` diff --git a/audit/11-architecture.md b/audit/11-architecture.md new file mode 100644 index 0000000..f012784 --- /dev/null +++ b/audit/11-architecture.md @@ -0,0 +1,369 @@ +# Architecture audit + +## Review + +- **Correct:** The primary dependency direction is sound: `brain-brew-core` has no dependencies; `brain-brew-formats` depends on core plus serialization/hash libraries; the CLI depends on both; and browser automation remains isolated in `brain-brew-workbench-e2e`. This matches ADR-002 at the coarse crate-graph level. +- **Correct:** The core has a real stable addressing Interface (`DeckPath`) rather than relying only on anonymous strings (`crates/brain-brew-core/src/model.rs:75-620`), and core translation code already uses it (`crates/brain-brew-core/src/translation.rs:845-904`). That is a useful deepening foothold. +- **Correct:** The canonical YAML Module is large but externally deep: a small parse/format/emit Interface hides strict schema conversion and deterministic emission (`crates/brain-brew-formats/src/canonical_yaml.rs:23-182`). Its size alone is not evidence that it should become more public Modules. +- **Correct:** The repository has unusually broad behavior coverage. `devenv shell test` passed all default Rust tests, including 133 CLI integration tests and 18 full Ultimate Geography fixture tests. +- **Blocker:** None for this review-only task. Findings 1-4 are high-priority architecture risks. +- **Note:** The requested `plan.md` and `progress.md` do not exist at the repository root, so there was no plan/progress context to reconcile with the implementation. + +## Ranked deepening opportunities + +### 1. High — Workbench multi-file “atomic” Apply can leave canonical source partially updated + +**Files / evidence** + +- `crates/brain-brew-cli/src/commands/workbench.rs:1013-1035` batches the Canonical Deck file first, then translation overlays, and calls `write_files_atomically`. +- `crates/brain-brew-cli/src/commands/workbench.rs:3878-3963` prepares all temporary files but renames them sequentially without rollback. +- `crates/brain-brew-cli/src/commands/workbench.rs:4004-4017` explicitly reports already-updated and not-updated files after a rename failure. +- `crates/brain-brew-cli/tests/cli.rs:1153-1184` attests the failure mode: `deck.yaml` is updated while `da.yaml` and `nb.yaml` are not. + +**Problem** + +The filesystem Adapter offers a misleadingly strong Interface. Each individual rename is atomic, but the batch is not. A source edit and its stale-translation/migrated-key updates are one domain operation; splitting them can leave the Canonical Deck and Translation Overlays semantically inconsistent. The current diagnostic makes partial failure visible but does not restore the invariant. + +The deletion test also fails: the transaction-looking seam cannot be removed or replaced locally because Workbench apply policy, serialization, and file ordering are embedded in one 4,985-line Implementation. + +**Solution direction** + +Create a dedicated workspace write transaction Module behind an honest Interface. At minimum: write and fsync all new files, preserve backups, record a recovery journal before renames, rollback completed renames on ordinary errors, and recover or clearly quarantine an interrupted transaction on next startup. Explicitly handle writes outside the package root/same filesystem; if all-or-nothing cannot be guaranteed there, reject such a batch or expose that limitation before confirmation. Rename the current helper if it remains only a durable batch writer. + +**Benefits** + +- Preserves locality of a domain Apply across all affected source files. +- Makes the filesystem Adapter deep: callers ask for one transaction instead of managing partial-file states. +- Removes hidden coupling between write order and source consistency. + +**Test impact** + +Replace the current “reports partial update” expectation with rollback/recovery tests for failures at every rename index, process-restart recovery tests, and cross-filesystem/external-include-root policy tests. Retain fsync and concurrent-Apply coverage. + +**ADR conflicts** + +No accepted ADR requires partial writes. This deepens ADR-011/015’s explicit Apply semantics and ADR-014’s file-mutation testing requirement. + +### 2. High — ADR-015’s list/detail Seam is only partially implemented; compatibility pivots remain the active detail API + +**Files / evidence** + +- ADR-015 requires `card-detail`, `source-string-detail`, and `metadata-detail` endpoints and calls full pivots temporary compatibility scaffolding (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:26-48`). +- The router has list endpoints, `note-detail`, and old full-pivot/metadata endpoints, but no other detail endpoints (`crates/brain-brew-cli/src/commands/workbench.rs:191-220`). +- Card detail requests still call `/api/workbench/card-pivot` (`crates/brain-brew-workbench-ui/src/lib.rs:4104-4127`), and source-string detail requests still call `/api/workbench/source-string-pivot` (`crates/brain-brew-workbench-ui/src/lib.rs:4172-4195`). Metadata still fetches `/api/workbench/metadata` (`crates/brain-brew-workbench-ui/src/lib.rs:4073-4081`). +- The old card response rebuilds and returns summaries for every matching card plus one selected detail (`crates/brain-brew-cli/src/commands/workbench.rs:2568-2635`); the source-string response returns every matching string plus selected occurrences (`crates/brain-brew-cli/src/commands/workbench.rs:2473-2565`); metadata returns every item (`crates/brain-brew-cli/src/commands/workbench.rs:2437-2470`). + +**Problem** + +The intended bounded Interface exists for navigation but not for three detail paths. This is an inappropriate Seam: UI code speaks in “detail” concepts while the Adapter invokes broad compatibility endpoints. On UG-sized decks, selecting one item still serializes unrelated summaries. By the deletion test, the compatibility endpoints are not temporary because production UI depends on them. + +**Solution direction** + +Implement the three specified detail endpoints with selected-item DTOs, migrate the UI and E2E tests, then delete the old pivot aliases and the `optional-metadata` aliases after a bounded compatibility window. Keep deck-wide progress on list responses; detail responses should contain only selected context. + +**Benefits** + +- Completes the accepted lazy architecture rather than adding another optimization layer. +- Bounds payload, projection, and DOM work per selection. +- Increases locality: list policy and selected-detail policy can evolve independently. +- Makes obsolete endpoint deletion possible. + +**Test impact** + +Add API tests asserting that detail payloads omit unrelated cards/strings/metadata and that unknown IDs/paths return 400. Update browser E2E network assertions to require the detail endpoints and forbid old pivots after migration. Preserve staged-edit navigation tests. + +**ADR conflicts** + +This resolves concrete friction with accepted ADR-015; it does not re-litigate the decision. + +### 3. High — YAML and filesystem Adapter responsibilities cross the documented crate boundary in both directions + +**Files / evidence** + +- The declared boundary puts codecs in formats and filesystem access in `brainbrew` (`documentation/docs/reference/project-scope.md:32-40`; ADR-002). +- The CLI directly depends on `serde_yaml` (`crates/brain-brew-cli/Cargo.toml:20-37`). +- Media commands parse, mutate, and serialize YAML ASTs (`crates/brain-brew-cli/src/commands/media.rs:48-65`, `101-115`, `602-604`). +- Workbench source editing implements YAML path traversal, tag handling, and emission in the CLI (`crates/brain-brew-cli/src/commands/workbench.rs:4112-4238`, `4433-4509`). +- Translation stub application performs its own line-oriented YAML section insertion in the CLI (`crates/brain-brew-cli/src/commands/translations.rs:2682-2765`). +- Conversely, formats performs real filesystem canonicalization and reads through `std::fs` in its include resolver (`crates/brain-brew-formats/src/source_includes.rs:1-45`, with the resolver beginning at line 333). + +**Problem** + +Format policy is duplicated across `canonical_yaml`, `source_includes`, media commands, translations, and Workbench. The CLI is no longer thin, while the reusable formats crate is not purely a codec Adapter. A source-format change can require edits in several CLI commands; include safety and source-preservation behavior cannot be tested independently of filesystem behavior. + +**Solution direction** + +Define pure source-document operations in `brain-brew-formats`: parse/mutate/emit Canonical Deck and Overlay source, preserve supported include directives, update media hashes, migrate image fields, and insert translation decisions. Keep reads, writes, canonicalization, and transaction policy in the CLI. For include expansion, inject a narrow loader callback/trait into formats rather than calling `fs` directly; the CLI Adapter should own path authorization and bytes retrieval. + +Do not create one Interface per command. Prefer a small, deep `CanonicalSourceDocument` / `OverlaySourceDocument` API that hides YAML AST details. + +**Benefits** + +- Restores ADR-002’s crate boundary and lowers change amplification. +- Gives format operations one deterministic Implementation and one test surface. +- Removes `serde_yaml` types from CLI code and improves AI navigation. +- Makes filesystem policy independently auditable. + +**Test impact** + +Move AST/source-preservation unit cases to formats; retain CLI integration tests for actual reads/writes. Add loader-fake tests for include cycles, allowed roots, and errors. A dependency check should assert that the CLI no longer directly depends on `serde_yaml` and formats’ pure codec layer does not call `fs`. + +**ADR conflicts** + +Aligned with ADR-002, ADR-005, and ADR-016. ADR-016 requires include-aware formatting/writeback behavior but does not require filesystem access to live in formats. + +### 4. High — Translation mutation policy lives in CLI Implementations and leaks through public collection fields + +**Files / evidence** + +- `TranslationDictionary` exposes all interacting collections publicly (`crates/brain-brew-core/src/model.rs:700-721`). Its one mutation method handles only basic stale promotion (`model.rs:723-747`). +- Workbench independently implements direct/contextual/no-change precedence, stale removal, and context cleanup (`crates/brain-brew-cli/src/commands/workbench.rs:4243-4393`, `4682-4795`). +- The translations command separately implements stale shadowing/resolution and context matching (`crates/brain-brew-cli/src/commands/translations.rs:1082-1335`) and a separate source-editing path (`translations.rs:2682-2820`). +- Core already owns translation resolution semantics in `crates/brain-brew-core/src/translation.rs`, so these mutations are domain policy, not terminal/report rendering. + +**Problem** + +The Translation Dictionary Module is shallow as a mutation Interface: callers receive raw maps and must know cross-map invariants. Adding a decision may require deleting entries from three other collections; stale shadow behavior is implemented differently for CLI resolution and Workbench editing. This is hidden coupling and duplicated policy at the wrong crate boundary. + +**Solution direction** + +Add core domain commands such as `set_direct`, `set_contextual`, `set_no_change`, `record_source_change`, and `resolve_stale`, returning a typed change report. Centralize descendant-context matching and shadow cleanup there. Migrate CLI and Workbench to these commands before considering private fields; do not hide collections until codecs have a workable construction Interface. + +**Benefits** + +- One policy Implementation for CLI, Workbench, and future Adapters. +- Higher Leverage from core tests and fewer stale-translation regressions. +- Makes the CLI thin and source-format editing orthogonal to domain edits. + +**Test impact** + +Move precedence/shadow/migration matrices into core TDD tests. Keep CLI/API tests to prove request mapping and file persistence. Add equivalence tests showing that CLI resolve and Workbench Apply produce the same dictionary for the same command. + +**ADR conflicts** + +Aligned with ADR-008 and ADR-013. It deepens their semantics rather than changing them. + +### 5. Medium — The Workbench HTTP Seam is untyped and unversioned despite being a separate-crate boundary + +**Files / evidence** + +- Server handlers and projection builders return ad hoc `serde_json::Value` (`crates/brain-brew-cli/src/commands/workbench.rs:233-360`, `601-779`, and many `json!` builders thereafter). +- The UI stores API state as `Value` and indexes string keys with fallback defaults (`crates/brain-brew-workbench-ui/src/lib.rs:1-35`, `947-1079`, `1287-1385`). +- Fetches deserialize every endpoint to `Value` (`crates/brain-brew-workbench-ui/src/lib.rs:3872-3882`, `3996-4004`, `4217-4229`). +- Staged edits are also anonymous JSON blobs with stringly typed modes/scopes (`crates/brain-brew-workbench-ui/src/staging.rs:33-104`). +- ADR-011 says the server/frontend API must be versioned and tested (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:33-36`). No API version is present in routes or DTOs. + +**Problem** + +This is a real process/crate Seam but has no shared Interface. Renaming a field compiles on both sides and degrades to `null`, empty arrays, or defaults at runtime. The many fallbacks conceal contract drift. E2E tests provide behavioral confidence but low diagnostic locality. + +**Solution direction** + +Introduce a small shared workbench-contract Module/crate containing serde request/response DTOs, enums for statuses/edit modes/scopes, stable query structures, and an explicit API version. Keep domain models out of it. Server projections return DTOs; the UI client deserializes them before views render. Unknown response fields may remain forward-compatible, but missing required fields should fail visibly. + +**Benefits** + +- Compile-time Leverage across server and UI. +- Smaller, navigable view Interfaces and fewer string-key lookups. +- Cleaner API versioning and more precise errors. + +**Test impact** + +Add DTO JSON round-trip/golden contract tests and server/UI compile-time use. Keep API integration and browser E2E tests required by ADR-014. + +**ADR conflicts** + +Aligned with ADR-011 and ADR-014. A new crate is justified here because two independently compiled workspace crates already share the contract; avoid placing domain behavior in it. + +### 6. Medium — `DeckPath` is a good Interface, but CLI code bypasses it and duplicates path/glob policy + +**Files / evidence** + +- `DeckPath` defines and parses the canonical grammar (`crates/brain-brew-core/src/model.rs:75-620`). +- Core compose contains roughly 250 lines of one-line path wrappers around `DeckPath` (`crates/brain-brew-core/src/compose.rs:1297-1549`). +- The translations command manually extracts note IDs, field IDs, and parents using string markers (`crates/brain-brew-cli/src/commands/translations.rs:3015-3061`). +- Workbench manually parses note-field paths (`crates/brain-brew-cli/src/commands/workbench.rs:4396-4430`) and manually derives note grouping elsewhere (`workbench.rs:1412-1423`, `3609-3612`). +- Core exports `glob_matches`, but Workbench carries another wildcard matcher (`crates/brain-brew-cli/src/commands/workbench.rs:3371-3405`). + +**Problem** + +The address grammar leaks as string manipulation. Any new path variant requires auditing many unrelated modules. Core’s constructor wrappers are shallow Modules: their Interface adds names but little abstraction, while callers outside core still cannot ask useful questions of a parsed path. + +**Solution direction** + +Deepen `DeckPath` with constructors/accessors such as `note_id()`, `field_id()`, `entity_parent()`, and `is_descendant_of()`. Use typed paths internally and stringify only at YAML/API boundaries. Reuse the exported wildcard matcher, or define one `DeckPathPattern` if wildcard semantics need stronger validation. Delete manual parsers and most one-line wrappers after migration. + +**Benefits** + +- One grammar and one descendant/context policy. +- Better locality for future entity/path additions. +- Safer metadata grouping and translation context matching. + +**Test impact** + +Extend table-driven path tests with accessor/parent/descendant cases, then replace CLI helper tests with shared core tests. Add a cross-check that metadata path matching and translation ignore-path matching use identical wildcard semantics. + +**ADR conflicts** + +Aligned with ADR-004, ADR-006, and ADR-008. + +### 7. Medium — Workbench and translation command files are oversized orchestration Modules with low internal Locality + +**Files / evidence** + +- `crates/brain-brew-cli/src/commands/workbench.rs` is 4,985 lines and contains CLI parsing, Axum routing, cache invalidation, workspace selection, list/detail projections, card rendering, media serving, new-language scaffolding, staged-edit policy, YAML mutation, and durable writes. +- `crates/brain-brew-workbench-ui/src/lib.rs` is 4,243 lines and contains API access, app state, every pivot/view, direct DOM mutation, preview patching, request generations, and storage coordination. +- `crates/brain-brew-cli/src/commands/translations.rs` is 3,346 lines and combines argument parsing, interactive terminal UI, reports, stale resolution, source editing, and filesystem discovery. +- `crates/brain-brew-core/src/compose.rs` is 2,169 lines and combines composition, render lowering, and semantic diff. + +**Problem** + +These Modules are deep only at their outermost `run`/`App` entry points; internally they provide few named seams between unrelated policies. A change has high search cost and broad merge-conflict surface. This is significant AI-navigation friction. In contrast, `canonical_yaml.rs` is large but substantially cohesive around one Adapter, so it is not the first split target. + +**Solution direction** + +Split by responsibility using private/internal Modules, not new public crates by default: + +- Workbench server: `api/routes`, `contracts/projections`, `workspace/cache`, `apply/domain`, `apply/source_io`, `media`, `new_language`. +- UI: `api_client`, `state`, `views/{notes,cards,source_strings,metadata,apply}`, `preview`, `staging`. +- Translations command: `args`, `terminal`, `report`, `resolve`, `source_editor`. +- Core: `compose`, `render`, `semantic_diff`, with the public `CanonicalDeck` methods unchanged. + +Keep each new Interface deep and task-oriented. Do not create pass-through files merely to reduce line counts. + +**Benefits** + +- Better Locality and ownership boundaries. +- Smaller review surfaces and easier deletion of compatibility code. +- Faster, more reliable navigation for humans and AI tools. + +**Test impact** + +Primarily refactor under existing tests. Add focused unit tests only where extracted pure projections/policies currently require full CLI integration setup. + +**ADR conflicts** + +None. This preserves accepted crate-level architecture. + +### 8. Medium — Active architecture records conflict with the implemented Workbench framework and omit two workspace crates + +**Files / evidence** + +- ADR-011 remains Accepted and requires Iced/WASM (`documentation/docs/reference/decisions/0011-use-a-local-deck-workbench-server-with-iced-wasm-ui.md:1-48`); ADR-014 also describes the real browser as Iced/WASM. +- The implementation and current reference docs use Leptos: `crates/brain-brew-workbench-ui/Cargo.toml:9-24`, `crates/brain-brew-workbench-ui/src/lib.rs:7-17`, and `documentation/docs/reference/workbench.md:13-29`. +- The current project scope and root README list only core, formats, and CLI (`documentation/docs/reference/project-scope.md:32-38`; `README.md:81-88`), while the workspace has five crates. + +**Problem** + +The accepted decision log and code disagree about a major Implementation choice. Agents following `AGENTS.md` are told to read the ADR index before design work, then receive obsolete guidance. The crate map also hides the API/UI/E2E seams. This is direct AI-navigation friction, not a request to re-litigate Iced versus Leptos. + +**Solution direction** + +Record the actual framework decision in a superseding ADR (including why Leptos replaced Iced and consequences), update ADR-014 terminology if appropriate, and update project scope/README with the UI and E2E crates and their dependency rules. Keep historical ADR status honest rather than silently rewriting the old decision. + +**Benefits** + +- Restores the architecture documents as reliable Interfaces for contributors. +- Prevents repeated framework debate and incorrect implementation plans. +- Makes the full workspace graph discoverable. + +**Test impact** + +Documentation-only. A lightweight doc/metadata check could verify that listed workspace crates match `cargo metadata` and that active ADR links exist. + +**ADR conflicts** + +This finding identifies the conflict with ADR-011/014. Resolution should supersede or explicitly amend them; it should not silently treat the implementation as self-authorizing. + +### 9. Low — Filesystem path-safety policy is duplicated, including one divergent media-server variant + +**Files / evidence** + +- Identical media path validation exists in `crates/brain-brew-cli/src/media_assets.rs:67-76` and `crates/brain-brew-cli/src/commands/media.rs:618-627`. +- Workbench uses a different string-based rule (`crates/brain-brew-cli/src/commands/workbench.rs:3817-3826`). +- Workbench source includes use yet another `contains("..")` rule (`workbench.rs:4511-4518`) instead of the package include resolver. + +**Problem** + +Path authorization is security-sensitive Adapter policy. Duplicate predicates have different treatment of empty paths, `~`, absolute prefixes, and any name merely containing `..`. The deletion test shows no canonical seam: removing any copy breaks only one workflow. + +**Solution direction** + +Create one CLI-owned `SafeRelativePath` parser with explicit policies for media assets and writable include targets, based on path components plus canonical-root containment. Reuse the package include authorization logic where external roots are supported. Keep URL decoding/routing separate from filesystem authorization. + +**Benefits** + +- One auditable traversal policy. +- Consistent diagnostics and platform behavior. +- Removes duplicate helpers. + +**Test impact** + +Add a shared adversarial table for Unix/Windows prefixes, parent components, encoded URL input, empty/dot paths, Unicode, symlinks, and allowed external roots; run all media/include callers against it. + +**ADR conflicts** + +Aligned with ADR-010’s fail-closed posture and ADR-016’s include rules. + +## Lower-priority observations + +- `format_source_at(_path, input)` ignores its path (`crates/brain-brew-cli/src/io.rs:45-47`). By the deletion test, this is a shallow Interface: remove the parameter now, or make source-kind selection path-aware in the formats Module rather than carrying a false extension point. +- `brain-brew-core` remains dependency-pure, so there is no evidence for moving YAML, filesystem, terminal, or HTTP concerns into it. +- `brain-brew-formats/src/manifest.rs` contains manifest validation/target expansion as well as YAML conversion. ADR-009 explicitly places manifest codecs in formats, and no concrete regression was found; do not move it merely for conceptual tidiness. + +## Residual risks + +- `devenv shell e2e` was not run; ADR-014’s browser gate therefore remains unverified in this audit. +- The audit is static. The payload concern in finding 2 is demonstrated by response shape and computation, but no new UG payload/latency benchmark was collected. +- The missing root `plan.md` and `progress.md` may mean intended transitional work was unavailable; findings were ranked against accepted ADRs and current source only. +- The local working copy already contained other audit files before this report; they were not reviewed or modified. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "review-findings: nine ranked findings with severity and file/line evidence; residual-risks: four explicitly listed risks" + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Inspected the pre-existing working-copy state before review." + }, + { + "command": "cargo metadata --no-deps --format-version 1", + "result": "passed", + "summary": "Verified all five workspace crates and their direct dependency graph." + }, + { + "command": "devenv shell test", + "result": "passed", + "summary": "All default Rust unit and integration tests passed, including core, formats, CLI, UI host tests, and full UG fixture tests." + }, + { + "command": "source/ADR inventories, line counts, grep, and targeted read-only source inspection", + "result": "passed", + "summary": "Inspected all ADRs, all crate manifests, the complete source inventory, module declarations/public surfaces, and evidence ranges cited above." + } + ], + "validationOutput": [ + "brain-brew-core has no direct dependencies", + "brain-brew-formats depends on brain-brew-core plus serde/serde_json/serde_yaml/sha2", + "brainbrew depends on core, formats, CLI/server/filesystem libraries, and directly on serde_yaml", + "devenv shell test completed with zero failures" + ], + "residualRisks": [ + "Browser E2E gate was not run.", + "No performance benchmark was collected for full-pivot detail responses.", + "plan.md and progress.md were absent.", + "Pre-existing working-copy audit files were not reviewed." + ], + "noStagedFiles": true, + "notes": "Review-only: no product source or tests were edited. The requested audit report was written to audit/11-architecture.md." +} +``` diff --git a/audit/12-performance.md b/audit/12-performance.md new file mode 100644 index 0000000..ded8c6e --- /dev/null +++ b/audit/12-performance.md @@ -0,0 +1,169 @@ +## Review + +### Scope and baseline + +- Reviewed composition/translation complexity, manifest planning and package discovery, canonical/CrowdAnki serialization, media hashing, lock hashing, output ordering, and Workbench caching/list behavior. +- The requested `plan.md` and `progress.md` do not exist in this working copy, so no plan/progress assumptions could be verified. +- The checked-in Ultimate Geography fixture is a useful source/overlay workload: 74 targets, 16 languages, 319 base notes, 546 declared media references, 92 overlay files, 119 files total, and 1.4 MiB of source (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:145-179`; fixture inspection commands below). It does not contain the real media payload, so media-byte conclusions below are code-derived rather than measured against release assets. + +### Correct + +- Deterministic ordering is designed in rather than repaired after the fact. Domain collections, manifests, overlay dependency visitation, and serialization use `BTreeMap`/`BTreeSet`; target dependency expansion is deterministic (`crates/brain-brew-formats/src/manifest.rs:22-42,124-145`), and CrowdAnki exports iterate those ordered collections before pretty serialization (`crates/brain-brew-formats/src/crowdanki.rs:18-80`). No production `HashMap`/`HashSet` use was found. +- The real UG fixture's all-target composition test passed, and its byte-idempotent formatter test passed. The latter covers manifests, decks, the media map, overlays, and lockfiles (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:182-230`). +- Lock content hashing is reproducible and bounded-memory: the filtered source snapshot is encoded as NAR and SHA-256 is updated through a 64 KiB buffer (`crates/brain-brew-cli/src/commands/lock.rs:540-581,767-783`). Cached sources are rehashed before reuse (`:584-601`). +- A direct UG `verify --all-targets --skip-content-validation` probe completed successfully in 18.910 s with 18.803 s user CPU and sampled peak RSS of only 24,308 KiB. A large source/overlay stack therefore works today without excessive peak memory when no media root or golden export is requested. + +### Ranked findings + +#### P1 / High — 1. Workbench selection caching grows without a bound and retained about 430 MiB across the valid UG selection matrix + +**Evidence.** `WorkspaceCache.selected_contexts` is an unrestricted map keyed by language/target/overlay and is only cleared on a workspace generation change (`crates/brain-brew-cli/src/commands/workbench.rs:464-493`). Every entry retains a `SelectedTranslationContext` containing three full `CanonicalDeck`s, every planned overlay, and the full coverage report (`:1746-1754`; construction at `:1273-1284`). Cache hits also clone the whole cached value into each request (`:1186-1226`). + +A local Workbench probe requested `note-list?limit=1` for all 93 valid target-language/target/overlay selections in the UG manifest. RSS rose monotonically from **20,228 KiB to 448,672 KiB**; all requests succeeded. The run took 32.368 s (mean cold selection 0.348 s, maximum 0.739 s). Intermediate RSS was 70,960 KiB after 10 selections, 142,032 KiB after 25, and 253,616 KiB after 50. This is retained growth, not a transient peak. + +**Likely impact.** A maintainer comparing many languages/variants can push a local Workbench process toward hundreds of MiB on the current 319-note fixture; larger decks multiply the retained cost. This conflicts with ADR-0015's UG responsiveness goal (`documentation/docs/reference/decisions/0015-use-lazy-single-work-item-workbench-editing.md:15-24,92-95`). + +**Measurement/fix test.** Add a UG API test with observable cache statistics (entry count and estimated retained bytes), traverse the selection matrix, and require a bounded LRU/single-active-selection policy. Store shared immutable decks/overlays behind `Arc` and return shared contexts instead of deep clones. Track RSS or allocator bytes in a non-flaky benchmark, not as a normal unit-test wall-clock assertion. + +#### P1 / High — 2. The first Workbench media request composes every manifest target synchronously + +**Evidence.** On a cache miss, `media_path_declared` calls `collect_declared_media_paths`; that loops through every target, creates a fresh manifest target plan, and composes every overlay state before serving the requested asset (`crates/brain-brew-cli/src/commands/workbench.rs:1127-1183`). On UG, a request for one declared but absent image (`/api/media/ug-flag-abkhazia.svg`) took **14.630 s** and returned the 393-byte placeholder. The identical second request took **0.002 s** after the global declared-path cache was populated. + +**Likely impact.** The first card/note preview that requests media can appear hung for roughly the cost of all-target verification, even though it needs one path. More targets and overlays worsen first-image latency. This directly conflicts with ADR-0015's fast-visible-feedback goal. + +**Measurement/fix test.** Add a real UG server test/benchmark for first and warm media fetches, with an internal counter proving one selected-target parse at most. Build declared paths cheaply from the base plus overlay media deltas, precompute in a background startup task, or lazily validate against only the active target; do not compose all targets on the request critical path. + +#### P1 / High — 3. `--all-targets --media-root` rereads and retains every media asset once per target + +**Evidence.** Verification invokes `validate_media_assets` inside the per-target loop (`crates/brain-brew-cli/src/commands/verify.rs:51-79`). That function reads every declared file completely into `BTreeMap>` before hashing any of them (`crates/brain-brew-cli/src/media_assets.rs:23-45`); `validate_hashes` then makes a second pass over those resident buffers (`crates/brain-brew-formats/src/media.rs:89-127`). For UG this means up to 74 target passes over 546 declarations—**40,404 file reads/hash checks** if sets are identical—rather than hashing each distinct asset once. Peak asset memory is the sum of all media bytes for one target, not a streaming buffer. + +**Likely impact.** This is negligible for the source-only fixture but potentially dominant for real image/audio decks: repeated disk I/O and SHA-256 work scale as `targets × total media bytes`, while peak RSS scales as total media bytes. Large videos/audio can cause OOM even though SHA-256 itself is streamable. + +**Measurement/fix test.** Use a synthetic 74-target workspace sharing, for example, 500 × 1 MiB assets and count opens/bytes read with an injected reader or syscall tracer. Require one streaming hash per unique `(canonical path, expected digest)` per verify invocation and bounded peak buffer size (for example 64 KiB), while still reporting target-specific missing/hash errors deterministically. + +#### P1 / High — 4. Workbench freshness excludes all `!include` targets, so cached results can remain stale indefinitely + +**Evidence.** Cache invalidation signatures include only the manifest, top-level base file, and top-level overlay files (`crates/brain-brew-cli/src/commands/workbench.rs:517-559,573-598`). Workspace SHA-256 fingerprints use the same incomplete file set (`:4929-4952`). Yet deck/overlay reads materialize scalar and structural media includes before parsing (`crates/brain-brew-cli/src/io.rs:68-71,88-91`; include resolution at `crates/brain-brew-formats/src/source_includes.rs:15-36,367-390`). The UG fixture deliberately puts descriptions, templates, styling, translations, and its 546-entry media map in includes. + +**Likely impact.** Editing an included template/description/media map externally does not advance the generation or clear `selected_contexts`; subsequent Workbench requests can show an old composed deck while the CLI sees new source. This is primarily a cache-correctness defect, with determinism/reproducibility implications for preview/apply workflows. + +**Measurement/fix test.** Start Workbench on the real UG fixture, warm a context, edit each include kind (scalar nested include and top-level `media` include), and require the next response/fingerprint to change without touching the parent YAML. Have include resolution return its dependency set and signature/hash that complete transitive set. + +#### P2 / Medium — 5. All-target verification reparses and recomposes shared source serially for every target + +**Evidence.** The verify loop creates a fresh `ManifestRegistry` per target and re-verifies each overlay file (`crates/brain-brew-cli/src/commands/verify.rs:51-70`; `crates/brain-brew-cli/src/io.rs:173-180,196-233`). Planning rereads/parses the base and all expanded overlays (`io.rs:298-325`). Translation-policy checking then composes one overlay at a time (`verify.rs:136-185`), after which `plan.compose()` starts over and composes the complete stack. `CanonicalDeck::compose` clones the deck and validates after every call (`crates/brain-brew-core/src/compose.rs:10-40`), and verify immediately validates the final deck again. + +Measured on the UG fixture, one complex target took **0.500 s**, while all 74 took **18.910 s** (single process, debug binary, content validation skipped). CPU nearly equaled elapsed time, confirming serial CPU-bound work. Peak RSS remained good at about 24 MiB, so this finding is throughput rather than memory. + +**Likely impact.** Current CI cost is noticeable but tolerable; target counts, package roots, and overlay stacks will increase it roughly linearly while repeatedly paying YAML parsing, include resolution, translation coverage, clones, and validation for common prefixes. + +**Measurement/fix test.** Add Criterion or a dedicated perf harness that records parse count, composed-overlay count, cloned deck bytes, and elapsed time for 1/10/74 UG targets. Load the registry/base/overlay catalog once, memoize deterministic composition prefixes, and only then consider bounded parallel target evaluation with results emitted in manifest order. + +#### P2 / Medium — 6. Workbench pagination bounds response rows but not server computation or allocation + +**Evidence.** Note lists build progress, metadata, and all navigation rows before `paginate` (`crates/brain-brew-cli/src/commands/workbench.rs:2119-2135`). Card lists produce every card, filter all cards, and create all summaries before pagination (`:2165-2206`). Source-string lists build/group the full row set twice, clone source keys, then paginate summaries (`:2244-2300`). + +On a warmed German hardcore-extended UG context, median request times were effectively independent of page size: note list 66.6 ms (`limit=1`) vs 71.0 ms (`limit=50`), card list 59.8 vs 58.8 ms, and source-string list 51.3 vs 54.1 ms. Response bytes were bounded, but CPU/allocation work was not. + +**Likely impact.** The browser receives compact pages, but interaction latency still scales with total notes × templates/source occurrences, undermining the intended benefit on decks much larger than UG or with many templates. + +**Measurement/fix test.** Benchmark warm endpoint CPU/allocation for 319/3,190/31,900 notes at `limit=1` and `50`. Cache compact navigation/progress indexes per selection, paginate before expensive detail rendering, and assert response construction work is near `O(total-for-required-totals + page size)` rather than rebuilding full card/detail objects. + +#### P2 / Medium — 7. Package discovery is an unpruned recursive filesystem walk, and all-target planning can repeat it per target + +**Evidence.** `discover_package_manifests` recursively descends every directory under every supplied package root (`crates/brain-brew-cli/src/package_resolver.rs:7-13,64-86`). It does not skip `.git`, `.jj`, `target`, cache/output directories, or stop beneath a discovered package; `Path::is_dir` follows directory symlinks and there is no visited-directory set. By contrast, lock snapshot copying explicitly skips VCS/build output (`crates/brain-brew-cli/src/commands/lock.rs:796-831`). Registry construction invokes discovery, and finding 5 shows registry construction repeats per target. + +**Likely impact.** A broad package root can traverse millions of irrelevant files, and a symlink cycle can recurse until an OS/error limit. This was not benchmarked against the 24 GiB repository root to avoid an intentionally pathological audit run; magnitude is therefore unmeasured, but the traversal behavior is confirmed. + +**Measurement/fix test.** Build a fixture containing a large `target/`, nested VCS dirs, inaccessible dirs, and `loop -> ..`; assert bounded visits, no cycle, deterministic manifest order, and one discovery pass per command. Support explicit manifest indexes or prune known build/VCS trees and canonicalize/track visited directories. + +#### P3 / Low now, scale risk — 8. Translation lookup can become quadratic with path-scoped overlay size + +**Evidence.** Every extracted non-empty source occurrence scans all contextual path maps before direct/no-change fallback (`crates/brain-brew-core/src/translation.rs:240-309`), scans all stale records on fallback (`:1804-1821`), and scans every ignored glob (`:1823-1827`). Coverage walks all deck fields (`:48-135`), and applying a translation additionally clones the whole source deck (`:1108-1121`). Thus a per-note contextual overlay can approach `O(extracted occurrences × contextual paths)`; stacked translation coverage plus composition repeats the lookup. + +**Likely impact.** No isolated UG regression was demonstrated—the measured cold Workbench selections include parsing/composition and remained below 0.74 s—so the practical severity at current UG sizes is low. A generated overlay with thousands of path-specific contexts/stale records is the unmeasured risk. + +**Measurement/fix test.** Add synthetic 1k/10k/100k occurrence benchmarks with proportional contextual/stale/ignore entries and verify slope. Index exact paths and path prefixes once per dictionary, and index stale records by `new_source`; preserve longest-context semantics and deterministic reporting. + +### Notes / residual risks + +- CrowdAnki export holds a rendered deck clone, typed export tree, and final JSON string simultaneously (`crates/brain-brew-formats/src/crowdanki.rs:18-80`). Golden verification additionally holds expected/actual strings and both parsed `serde_json::Value` trees (`crates/brain-brew-cli/src/commands/verify.rs:321-340`). This is a plausible multi-copy memory issue for very large note text, but it was not observed at UG size and is not ranked above the measured Workbench/media issues. +- No dedicated benchmark suite or large-media fixture was found. Existing UG tests are strong semantic/determinism regressions but have no time, allocation, parse-count, I/O-count, or cache-bound assertions. +- The production paths are intentionally sequential. This supports deterministic diagnostics but leaves safe target/media parallelism unused. Optimize shared parsing/composition and bounded memory first; only parallelize after preserving ordered output and avoiding multiplied peak RSS. +- The direct CLI probes used the current `target/debug/brainbrew`; its timestamp postdates all reviewed performance-critical source files. Timings are comparative local observations, not release-mode service-level objectives. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Eight ranked findings include severity, concrete file/line references, impact, and measurement/test suggestions; Workbench cache growth/media latency, UG verify timing/RSS, and pagination behavior were directly probed." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell cargo test -p brain-brew-formats --test ultimate_geography_fixture ultimate_geography_fixture_manifest_composes_all_targets -- --exact", + "result": "passed", + "summary": "UG all-target composition test passed: 1 passed in 13.66 s." + }, + { + "command": "devenv shell cargo test -p brain-brew-formats --test ultimate_geography_fixture ultimate_geography_fixture_formatting_is_byte_idempotent -- --exact", + "result": "passed", + "summary": "UG deterministic formatting test passed: 1 passed in 1.19 s." + }, + { + "command": "TIMEFORMAT=...; time target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --target de-hardcore-extended --skip-content-validation", + "result": "passed", + "summary": "One complex target verified in 0.500 s (0.490 s user CPU)." + }, + { + "command": "TIMEFORMAT=...; time target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets --skip-content-validation", + "result": "passed", + "summary": "All 74 targets verified in 18.910 s (18.803 s user CPU)." + }, + { + "command": "sample /proc//status while target/debug/brainbrew verify ... --all-targets --skip-content-validation", + "result": "passed", + "summary": "All-target verification completed with sampled maximum VmRSS 24,308 KiB." + }, + { + "command": "start Workbench on UG and request note-list limit=1 for all 93 valid language/target/overlay selections while sampling /proc//status", + "result": "passed", + "summary": "All requests succeeded; retained RSS grew from 20,228 KiB to 448,672 KiB in 32.368 s." + }, + { + "command": "request the same declared UG media path twice from a fresh Workbench server", + "result": "passed", + "summary": "First request took 14.630 s while composing all target media catalogs; the cached second request took 0.002 s." + }, + { + "command": "warm UG Workbench context and compare note/card/source list requests at limit=1 and limit=50", + "result": "passed", + "summary": "Responses were byte-bounded, but warm median compute stayed about 51-71 ms independent of page size." + }, + { + "command": "fixture/source inspection with du, find, wc, rg, jj status, and line-numbered source reads", + "result": "passed", + "summary": "Confirmed UG/source sizes, deterministic collection use, recursive walking, clone/parse paths, and pre-existing audit-only working-copy changes." + } + ], + "validationOutput": [ + "UG fixture: 74 targets, 16 languages, 319 base notes, 546 media references, 92 overlays, 119 files, 1.4 MiB source.", + "Verify all targets: elapsed=18.910 user=18.803 sys=0.028; sampled_maxrss_kb=24308.", + "Workbench 93-selection probe: baseline_rss_kb=20228 final_rss_kb=448672 elapsed_s=32.368 failures=0.", + "Workbench first/warm media fetch: 14.630 s / 0.002 s for the same declared path.", + "Warm pagination probe: note 0.0666/0.0710 s, card 0.0598/0.0588 s, source 0.0513/0.0541 s for limit 1/50." + ], + "residualRisks": [ + "Real Ultimate Geography media bytes were unavailable, so repeated media I/O and peak media memory were established from code rather than measured on release assets.", + "No release-mode benchmark or allocator profile was run; debug timings are comparative only.", + "Contextual-translation quadratic behavior and broad package-root traversal magnitude were not stress-run; both are explicitly marked as unmeasured scale risks.", + "plan.md and progress.md requested by the task were absent." + ], + "noStagedFiles": true, + "notes": "Review-only: no product source or tests were edited. audit/12-performance.md is the requested report artifact and is excluded from changedFiles. Jujutsu has no Git staging area; no commit/staging operation was performed." +} +``` diff --git a/audit/13-ultimate-geography.md b/audit/13-ultimate-geography.md new file mode 100644 index 0000000..77c097f --- /dev/null +++ b/audit/13-ultimate-geography.md @@ -0,0 +1,382 @@ +# Ultimate Geography production-consumer audit + +## Review + +- **Correct:** UG's migrated source follows the intended variable-first/shared-extension model in important places. The base note type defines reusable variables (`deck.yaml:11-21`), standard templates are external includes (`deck.yaml:53-74`), Extended templates exist once (`overlays/variants/extended.yaml:1-28`), and per-language variant overlays are normally identity-only 11-line files. Hardcore blank-field content uses `field_fills` (`overlays/extensions/hardcore/field-fills.yaml:1-84`). +- **Correct:** Native composition works once source formatting/media state is made compatible in a temporary copy. The current binary listed 74 main targets and 26 companion targets. A canonically formatted temporary copy of `brainbrew-hardcore.yaml` verified all 26 targets, and `de-hardcore-companion-standard` exported 45 notes, one note type, four templates, and 56 declared media assets. +- **Blocker:** The actual production checkout cannot pass its documented verification or export its main deck with the installed/current Brain Brew. Details are B1-B3 below. +- **Note:** No source file in either repository was modified. This report is the only requested output. + +## Scope and baseline + +Paths used below: + +- **UG:** `/home/jmo/Development/external/ultimate-geography` +- **BB:** `/home/jmo/Development/projects/brain-brew` + +The requested `BB/plan.md` and `BB/progress.md` do not exist, so there was no plan/progress state to inspect. UG is not currently its upstream production branch: `@-` is `cc886bac5f8e` (`docs: address PR 736 Brain Brew review concerns`), four commits above bookmark `brainbrew-hebrew-demo`, while `master`/`brainbrew-migration` remain at `af35d4d14867`; `master@upstream` is a divergent head at `fee657631c04`. The working copy also contains untracked `.frontloop/**` planning records and `scripts/__pycache__/...pyc`. + +## Consumer journey map + +| Journey | UG source/workflow | Brain Brew path | Observed result | +|---|---|---|---| +| Install/discover CLI | `CONTRIBUTING.md:24-45` pins `1.0.0-alpha.1`, while CI uses a moving Nix branch at `.github/workflows/integrity-check.yml:13-15` | CLI dispatch/help; Nix package in `BB/flake.nix:22-49,57-69` | Profile-installed alpha runs, but rejects current UG. The documented Nix command currently cannot build (B2). A devenv `brainbrew` wrapper also shadows the installed binary and runs `cargo` in the caller CWD (`BB/devenv.nix:29-31`). | +| Discover build matrix | `brainbrew.yaml:355-605`; `brainbrew-hardcore.yaml:282-373` | Manifest parsing/overlay dependency expansion at `BB/crates/brain-brew-formats/src/manifest.rs:22-42,124-155`; package planning at `BB/crates/brain-brew-cli/src/io.rs:265-360` | 74 standalone/main targets plus 26 Hardcore companion targets are discoverable. Overlay order is deterministic. | +| Edit English/source content | `CONTRIBUTING.md:104-116`; monolithic `deck.yaml` (7,154 lines) plus external templates/descriptions/styles | Canonical YAML/includes; format gate at `BB/crates/brain-brew-cli/src/io.rs:511-535` | Direct YAML editing is the only maintained path. Current checked-in source is not canonical under the current formatter (B1). The former CSV and Anki-to-source workflows were removed (H5). | +| Edit translations | `overlays/languages/*.yaml`; commands at `CONTRIBUTING.md:160-205` | Reports/apply at `BB/crates/brain-brew-cli/src/commands/translations.rs:23-94,982-1024` | Terminal reports work and detect no stale keys, but release-strict mode is unusable and translation debt remains (H4). | +| Use browser workbench | `CONTRIBUTING.md:185-199` | Server/routes at `BB/crates/brain-brew-cli/src/commands/workbench.rs:156-220`; selection requires catalog entries at `:1290-1388` | Server shell starts, but the real manifest returns `languages: {}` and translation APIs cannot select a language. The documented workflow is not usable (H2). | +| Build Standard/Extended/Experimental | Base variables/templates in `deck.yaml:11-74`; shared variants at `overlays/variants/extended.yaml:1-28` and `overlays/variants/experimental.yaml:1-684` | Compose plan then `CanonicalDeck::compose`; export starts at `BB/crates/brain-brew-cli/src/commands/export.rs:34-58` | Structure is clean and shared. Experimental adds one field across all notes and five interactive assets, but all five hashes are blank (`overlays/variants/experimental.yaml:664-684`). | +| Build Hardcore standalone/companion | Two manifests; minimal shell `deck-hardcore.yaml:1-79`; 45-note shared overlay; field fills and three translation overlay families | Same manifest planner/compose path | Companion identity preservation works, but requires duplicated shell/catalog/translation source (M1). | +| Verify for CI/release | `CONTRIBUTING.md:54-61`; CI `.github/workflows/integrity-check.yml:25-33` | Sequential format, translation, compose, validation, media, HTML/CSS and golden gates at `BB/crates/brain-brew-cli/src/commands/verify.rs:18-90` | Both documented main verification and CI are currently blocked (B1-B2). The side HTML/CSS script passes 22 files. | +| Export/package release | `CONTRIBUTING.md:63-90,488-498`; CI export loop at `.github/workflows/integrity-check.yml:35-45` | CrowdAnki conversion/media copy at `BB/crates/brain-brew-cli/src/commands/export.rs:86-120` and `media_assets.rs:23-64` | Main export fails on hashes. CI/docs then manually copy all 607 media into every target, bypassing declarations and duplicating about 1.62 GiB across 100 outputs (B1/M2). No replacement exists for old ZIP naming/packaging (M2). | +| Prove learner upgrade safety/parity | `docs/pr736-equivalence-evidence.md`; learner upgrade warnings at `README.md:119-178,220-294` | CrowdAnki adapter IDs plus optional manifest goldens (`BB/crates/brain-brew-cli/src/commands/verify.rs:303-351`) | Representative note GUID/content evidence exists, but it is not a sufficient release oracle and no golden is configured (H3). | +| Round-trip Anki edits | Former `recipes/anki_to_source*.yaml`; no current UG docs | Import only writes a new deck and requires accepting generated IDs at `BB/crates/brain-brew-cli/src/commands/import.rs:8-37` | No merge-back path into the canonical deck/overlays; a previously supported maintainer flow is gone (H5). | + +## Severity-ranked findings + +### B1 — Blocker: the actual checkout cannot verify or export the main deck + +**Evidence** + +1. Both the profile-installed `brainbrew 1.0.0-alpha.1` and `BB/target/debug/brainbrew` fail immediately: + +```text +$ brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media +./deck.yaml is not in canonical format + +$ brainbrew verify --manifest brainbrew-hardcore.yaml --all-targets --media-root media +./overlays/extensions/hardcore.yaml is not in canonical format +``` + +`verify` deliberately checks manifest/base/each selected overlay before composition (`BB/crates/brain-brew-cli/src/commands/verify.rs:24-40,51-60`), comparing formatter output byte-for-byte (`BB/crates/brain-brew-cli/src/io.rs:523-535`). Formatting a temporary copy changed 20 source files. The dominant mismatch is 669 quoted `UG::*` tags in `deck.yaml` and 93 in `overlays/extensions/hardcore.yaml`; e.g. `deck.yaml:89-90` is rewritten from quoted to unquoted tags. + +2. Canonicalizing a temporary copy only reveals the next blocker: all 546 base media declarations have `sha256: ''` (`deck.yaml:5515-7153`), as do all five Experimental assets (`overlays/variants/experimental.yaml:664-684`). Current verification/export validates hashes whenever `--media-root` is supplied (`BB/crates/brain-brew-cli/src/commands/verify.rs:47-75`; `export.rs:86-100`; `media_assets.rs:23-26`). The result is 546 errors such as: + +```text +ug-flag-abkhazia.svg: media entry media.ug-flag-abkhazia-svg (...) has empty sha256 +``` + +3. The release export itself therefore fails and creates no output: + +```text +$ brainbrew export crowdanki --manifest brainbrew.yaml --target en-standard \ + --out /tmp/ug-export-en-standard --media-root media +# exits 1 on empty sha256; /tmp/ug-export-en-standard/deck.json is absent +``` + +By contrast, the Hardcore overlay's 56 declarations are hashed (`overlays/extensions/hardcore.yaml:823-1047`), so a canonicalized temporary companion workspace verifies all 26 targets and exports successfully. This isolates the failure to checked-in main source state, not general composition. + +**Impact:** No main Standard, Extended, Experimental, or standalone Hardcore release target can pass the documented release gate or export with media. The release process at `CONTRIBUTING.md:488-498` is blocked. + +### B2 — Blocker: UG CI tracks a moving Brain Brew branch that currently does not build + +UG CI sets `BRAINBREW_FLAKE=github:jeprecated/brain-brew/rust-brainbrew` (`.github/workflows/integrity-check.yml:13-15`) and invokes it for every verify/export (`:28-41`). This is neither the contributor-pinned alpha (`CONTRIBUTING.md:28-42`) nor an immutable commit. + +The exact documented command failed during this audit: + +```text +$ nix run github:jeprecated/brain-brew/rust-brainbrew -- --version +error: Cannot build ... brainbrew-1.0.0-alpha.1.drv +... +13 workbench E2E failures: brainbrew test binary not found at /build/source/target/debug/brainbrew +``` + +The flake package runs `cargo test --workspace --all-targets` (`BB/flake.nix:38-41`), which includes the E2E crate, but that crate expects a separately built `target/debug/brainbrew`. The normal devenv test command explicitly excludes E2E (`BB/devenv.nix:34-36`) and its E2E script builds the binary first (`:53-81`). + +**Impact:** A UG push can fail without a UG change; today it cannot even reach UG verification. The unpinned tool can also introduce formatter/hash behavior that contributors' pinned binary does not share. + +### B3 — Blocker: the migration branch is divergent and not release-integrated + +`jj log` shows: + +- `master`/`brainbrew-migration` at `af35d4d14867`, while four later migration commits and the empty working-copy commit are above a different bookmark; +- `master@upstream` at `fee657631c04` on a divergent line; +- six upstream-only commits, including merged PRs #733 and #735 and four Hebrew commits; +- eleven migration-only commits. + +The current migration includes its own Hebrew implementation (`ea27eb4a52b9`) while upstream independently merged Hebrew, so a non-trivial reconciliation is required. The equivalence report's old baseline is the moving expression `master@upstream` (`docs/pr736-equivalence-evidence.md:9,18-23`), not an immutable commit ID. + +**Impact:** Even after source/tool fixes, this state cannot be released or merged without rebasing/reconciling upstream content and rerunning parity. + +### H1 — High: Brain Brew's UG fixture is a forked future state, so green tests do not validate the production consumer + +The fixture sync script says it copies UG and applies only an ADR-0012 catalog delta (`BB/scripts/sync-ug-fixture.sh:49-66`), but the checked-in fixture materially differs from UG: + +- production `deck.yaml` has inline raw `` fields and 546 blank inline hashes; fixture `deck.yaml` uses structured `!image` values and `media: !include media.yaml`; +- production Hardcore source has raw images; fixture Hardcore has structured images and populated hashes; +- every language overlay and multiple Hardcore files differ; +- fixture manifests add `languages:` and `translation_profile:` (`BB/fixtures/ultimate-geography/brainbrew.yaml:606-850`) absent from production. + +Brain Brew's “real UG” composition, translation, CLI, workbench, and browser tests all target this fixture, e.g. `BB/crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:141-179,297-331`, `BB/crates/brain-brew-cli/tests/cli.rs:3042-3114,3336-3405`, and `BB/crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs:275-318`. The optional release-oracle test silently skips when no external oracle is installed (`ultimate_geography_fixture.rs:535-547`); no oracle is present in this checkout. + +**Impact:** Brain Brew can be green while actual UG is non-canonical, unexportable, and unable to use the workbench. This is the strongest fixture-coupling failure found. + +### H2 — High: the documented production Workbench is only a shell without a language catalog + +UG documents `brainbrew workbench serve --manifest brainbrew.yaml` as a current translation UI (`CONTRIBUTING.md:185-199`), but neither real manifest contains `languages:` or `translation_profile:`. Runtime evidence: + +```text +$ brainbrew workbench serve --manifest brainbrew.yaml --port 45671 --no-open +Workbench listening at http://127.0.0.1:45671 + +$ curl .../api/workspace +languages = {} +translation_profile = {all lists empty} + +$ curl '.../api/workbench/note-list?language=de&target=standard...' +HTTP 400: unknown language "de" +``` + +The backend requires a configured target language and overlay (`BB/crates/brain-brew-cli/src/commands/workbench.rs:1294-1319`), and new-language preview requires a template language (`:815-825`). Brain Brew tests pass only because the fixture splices in 16 languages (`BB/fixtures/ultimate-geography/brainbrew.yaml:606-787`). + +**Impact:** The advertised browser-based translator/maintainer journey is unavailable to the first production consumer. + +### H3 — High: current parity evidence is representative, stale-path-dependent, and weaker than its claims + +`docs/pr736-equivalence-evidence.md:40-51` covers only 12 of 100 configured targets: six main UG, three companion, and three standalone Hardcore. It omits all Danish, Hebrew, Italian, Norwegian, Dutch, Polish, Portuguese, Russian, Swedish, Chinese, and Traditional Chinese main parity except the selected German/French cases, and almost all Hardcore translations. + +More importantly, the script's claimed “note model/template signature” is only `(model name, template count)` (`scripts/collect-pr736-equivalence-evidence.py:300-301`). It does **not** compare template names/order/HTML, CSS, field schema/order, deck configuration, or media contents. `compare_decks` checks note fields/GUIDs and only `name`, deck UUID, and description metadata (`:304-355`). Standalone Hardcore checks only unioned GUID/card counts (`:450-476`). The script also rewrites all `!include` directives into a materialized temporary tree (`:184-228`), despite native includes now working, and writes machine-specific paths into the report (`docs/pr736-equivalence-evidence.md:9-14`). + +No target configures Brain Brew's stronger CrowdAnki golden support; real manifests contain no `exports.crowdanki.golden`, although the verifier supports it (`BB/crates/brain-brew-cli/src/commands/verify.rs:303-351`). + +**Impact:** The report is useful GUID/note-content evidence, especially for the repaired Hardcore companion identity, but it cannot support “no unintended deck regressions” for the release matrix or templates/styles/media. + +### H4 — High: release-strict translation verification cannot currently certify any translated target + +UG intentionally defaults to lenient coverage (`CONTRIBUTING.md:201-205`). On a canonically formatted temporary copy: + +```text +$ brainbrew verify --manifest brainbrew.yaml --target de-standard \ + --translation-coverage strict +translation coverage strict policy failed ... 1269 untranslated fallback(s) +``` + +The normal translator report separates 43 actionable missing strings from 1,226 hidden structural/media/tag fallbacks. The 43 visible German misses are structured message formats such as `{country_1} ({description_1})`. Strict mode, however, fails every `UntranslatedFallback`, including all hidden entries (`BB/crates/brain-brew-cli/src/commands/verify.rs:136-167`), so the documentation's suggested one-off release certification is not practically reachable without marking structural content explicitly. + +The all-target summary also shows companion translation overlays with roughly 808-854 visible misses and 1,358+ hidden fallbacks per language because translation is split across multiple overlay stages. There are no stale/invalid keys, which is good, but “no stale keys” is much weaker than translation completeness. + +**Impact:** There is no enforceable release-ready translation target today; maintainers must interpret lenient reports manually. + +### H5 — High: migration removed two real maintainer workflows without replacement + +Commit `4828fa512bb2` deletes the legacy CSV source, reverse recipes, and utilities: 38 files/4,450 lines, including `recipes/anki_to_source*.yaml`, `src/data/*.csv`, `utils/flag_similarity.py`, and `utils/zip_decks.py`. Current guidance replaces the multi-table content source with direct editing of a 7,154-line `deck.yaml` (`CONTRIBUTING.md:104-116`). + +Two important losses are not covered by Brain Brew: + +1. **Anki-to-source editing:** upstream docs explicitly supported editing English notes in Anki and pulling content into CSV. Current `brainbrew import crowdanki` only imports a complete folder into a new canonical file and requires `--accept-suggested-ids`; it does not reconcile into the existing deck/overlays (`BB/crates/brain-brew-cli/src/commands/import.rs:8-37`). +2. **Flag comparison:** the deleted 573-line utility calculated SVG geometry/color similarity and ΔE to support the domain workflow. Structured message fields improve storage but do not replace this analysis tool. + +**Impact:** The migration narrows contributor workflows beyond “legacy recipe syntax”; it removes round-trip editing and a geography-specific maintenance tool. These need an explicit accepted deprecation or replacement before calling UG fully migrated. + +### M1 — Medium: preserving Hardcore update identity requires substantial duplicated source/catalog wiring + +The safe companion fix is valuable: 45 old HG note GUIDs are preserved and separate from standalone UG identity. But the price is visible duplication: + +- `deck-hardcore.yaml:9-76` duplicates the entire UG note type from `deck.yaml:9-76`; only deck metadata and empty notes/media differ. +- Two manifests repeat the Hardcore, field-fill, translation, and variant catalog (`brainbrew.yaml:7-354`; `brainbrew-hardcore.yaml:5-281`). +- Twelve `companion-translations` files total 1,036 lines, twelve Hardcore translation files total 808 lines, and twelve note-type translation files add 222 lines. +- For German, Spanish, French, and Norwegian, every parsed source→target pair in the companion translation file (31/34/33/33 pairs respectively) is an exact duplicate of the main language dictionary. For example German `American Samoa`, `Autonomous region of Portugal.`, etc. appear both in `overlays/languages/de.yaml:24,38-45` and `overlays/extensions/hardcore/companion-translations/de.yaml:6-23`. +- Swedish needs a separate companion variant file (`brainbrew-hardcore.yaml:270-275`) to avoid the deck metadata overrides in `overlays/variants/extended/sv.yaml:3-19`. + +**Impact:** A shared translation correction can require multiple files, and the two note-type shells can drift. This is an awkward workaround around preserving distinct adapter identity for conceptually overlapping notes. + +### M2 — Medium: release packaging is manual, redundant, and internally inconsistent + +Brain Brew already copies only declared media during export (`BB/crates/brain-brew-cli/src/commands/export.rs:99-101`), but UG CI and docs then run `cp media/*` (`.github/workflows/integrity-check.yml:43-45`; `CONTRIBUTING.md:72-73,87-88`). This masks declaration gaps and copies all 607 files (17,441,184 bytes) into every one of 100 outputs—about 1.62 GiB before ZIP compression—even for a 45-note Hardcore companion that natively needs 56 files. + +The old `utils/zip_decks.py` derived `Ultimate_Geography_vX.Y_LANG[_EXTENDED].zip` and preserved display-name directories. The new release instructions merely say “Zip each exported CrowdAnki folder” (`CONTRIBUTING.md:493-496`), while the documented output folders are target IDs such as `build/crowdanki/en-standard` (`:66-90`) and no manifest target configures `exports.crowdanki.out` (`brainbrew.yaml:355-605`; supported at `BB/crates/brain-brew-formats/src/manifest.rs:245-250`). There is also no automated archive validation. + +**Impact:** Release assembly is easy to misname/mispackage and unnecessarily expensive. + +### M3 — Medium: release version text is duplicated and the release instructions point at the wrong source + +The release checklist says to update the version in `deck.yaml` and `README.md` (`CONTRIBUTING.md:490-493`), but neither file contains the current `v5.3` literal. It lives independently in six description fragments: + +- `descriptions/ultimate-geography/en.html:3` +- `es.html:3` +- `he.html:5` +- `sv.html:3` +- `zh.html:3` +- `zh-tw.html:3` + +`deck.yaml:4` only includes the English fragment. There is no source variable for release version. + +**Impact:** A release can silently ship mixed version labels across languages, and a maintainer following the checklist will not find the stated version field. + +### M4 — Medium: CI duplicates native HTML/CSS validation with a weaker side script + +`scripts/check-source-content.py:10-12,103-119` scans only hard-coded `descriptions`, `templates`, and `styles` directories. It passed 22 files, but it is maintained separately and cannot inspect inline/composed target content. Current Brain Brew `verify` already renders variables and validates every target's HTML/CSS unless explicitly skipped (`BB/crates/brain-brew-cli/src/commands/verify.rs:76-78,122-133`). + +This duplication is already acknowledged in UG backlog, but today the stronger native gate never runs because formatting/media fail first. + +## Positive implementation notes + +- Repeated labels and note-type names are source variables rather than copied template text (`deck.yaml:11-21`), and translation overlays use `translations.variables` for them. +- Extended structure is genuinely shared; language overlays do not duplicate card template HTML. +- External includes reduce template/style duplication and the standalone source checker passes all 22 files. +- Hardcore uses one shared 45-note content overlay and default field fills; the companion shell preserves old deck/note GUID behavior instead of merging identities unsafely. +- Current media files and source references are internally exhaustive by filename: a static scan found exactly 607 referenced names and 607 files, with no missing/unreferenced filenames. The failure is declaration/hash state, not absent binaries. +- The PR evidence does prove exact note-field/GUID parity for six representative UG targets and documents intentional Hardcore deltas rather than hiding them. + +## Recommended gate order + +1. Pin a buildable Brain Brew artifact/commit and make Nix packaging stop running unprepared browser E2E tests. +2. Reconcile the migration branch with `master@upstream`; use immutable revisions in parity evidence. +3. Run current `brainbrew fmt` over actual UG, then migrate all 607 media references/declarations/hashes and remove manual `cp media/*`. +4. Move the language catalog/profile into the real consumer (or remove the unsupported Workbench claim); eliminate the fixture-only splice and resync fixture from actual UG. +5. Establish release goldens/oracles for all release targets, with exact template/CSS/schema/media identity checks and explicit allowlists for intentional differences. +6. Decide explicitly whether Anki-to-source, flag analysis, and automated ZIP packaging are required maintainer workflows; restore or document their deprecation. +7. Make release version metadata single-source and test every localized description. +8. Only then enable strict coverage for selected release-ready translations and run the complete two-manifest release/import smoke test. + +## Reproduction command log + +Commands were run from UG unless prefixed with `cd BB`: + +```bash +# Repository state/history +jj status +jj log -r 'ancestors(@-, 30)' ... +jj log -r 'ancestors(master@upstream) ~ ancestors(@-)' ... +jj diff --from master@upstream --to @- --stat + +# Installed/current binaries +command -v brainbrew +brainbrew --version +/etc/profiles/per-user/jmo/bin/brainbrew --version +/home/jmo/Development/projects/brain-brew/target/debug/brainbrew --version + +# Source checks and target inventory +python scripts/check-source-content.py +brainbrew targets --manifest brainbrew.yaml +brainbrew targets --manifest brainbrew-hardcore.yaml + +# Actual production gates (failed) +/etc/profiles/per-user/jmo/bin/brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media +/home/jmo/Development/projects/brain-brew/target/debug/brainbrew verify --manifest brainbrew-hardcore.yaml --all-targets --media-root media +/home/jmo/Development/projects/brain-brew/target/debug/brainbrew export crowdanki --manifest brainbrew.yaml --target en-standard --out /tmp/ug-export-en-standard --media-root media + +# Temporary-copy diagnosis; no repository file modified +find /tmp/ug-fmt-audit -name '*.yaml' -print0 | xargs -0 -n1 BB/target/debug/brainbrew fmt +BB/target/debug/brainbrew verify --manifest /tmp/ug-fmt-audit/brainbrew.yaml --all-targets --media-root UG/media +BB/target/debug/brainbrew verify --manifest /tmp/ug-fmt-audit2/brainbrew-hardcore.yaml --all-targets --media-root UG/media +BB/target/debug/brainbrew export crowdanki --manifest brainbrew-hardcore.yaml --target de-hardcore-companion-standard --out /tmp/ug-export-hc-de --media-root media + +# Translation/workbench +BB/target/debug/brainbrew translations --manifest brainbrew.yaml --target de-standard +BB/target/debug/brainbrew translations --manifest brainbrew.yaml --all-targets --summary +BB/target/debug/brainbrew verify --manifest /tmp/ug-fmt-audit/brainbrew.yaml --target de-standard --translation-coverage strict +BB/target/debug/brainbrew workbench serve --manifest brainbrew.yaml --port 45671 --no-open +curl http://127.0.0.1:45671/api/workspace +curl 'http://127.0.0.1:45671/api/workbench/note-list?language=de&target=standard&limit=1' + +# CI tool path (failed before UG execution) +nix run github:jeprecated/brain-brew/rust-brainbrew -- --version + +# Fixture drift +cd BB +diff -qr --exclude=brainbrew.yaml --exclude=brainbrew-hardcore.yaml --exclude=media.yaml UG fixtures/ultimate-geography +rg -n 'fixtures/ultimate-geography' crates --glob '*.rs' +``` + +## Acceptance + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Review-only consumer audit completed without modifying either source repository; only audit/13-ultimate-geography.md was written as requested." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings include exact UG/Brain Brew file-line references, reproducible commands, observed pass/fail output, a 10-stage consumer journey map, and severity-ranked residual risks." + } + ], + "changedFiles": [ + "audit/13-ultimate-geography.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "python scripts/check-source-content.py", + "result": "passed", + "summary": "Checked 22 external HTML/CSS source files." + }, + { + "command": "/etc/profiles/per-user/jmo/bin/brainbrew targets --manifest brainbrew.yaml; ... brainbrew-hardcore.yaml", + "result": "passed", + "summary": "Discovered 74 main and 26 Hardcore companion targets." + }, + { + "command": "/etc/profiles/per-user/jmo/bin/brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media", + "result": "failed", + "summary": "Rejected deck.yaml as non-canonical." + }, + { + "command": "BB/target/debug/brainbrew verify --manifest brainbrew-hardcore.yaml --all-targets --media-root media", + "result": "failed", + "summary": "Rejected overlays/extensions/hardcore.yaml as non-canonical." + }, + { + "command": "format temporary copies, then verify both manifests", + "result": "partial", + "summary": "Hardcore verified all 26 targets; main failed on 546 empty base media hashes (and has five additional Experimental empty hashes)." + }, + { + "command": "BB/target/debug/brainbrew export crowdanki --manifest brainbrew.yaml --target en-standard --out /tmp/ug-export-en-standard --media-root media", + "result": "failed", + "summary": "Empty media hashes prevented deck.json creation." + }, + { + "command": "BB/target/debug/brainbrew export crowdanki --manifest brainbrew-hardcore.yaml --target de-hardcore-companion-standard --out /tmp/ug-export-hc-de --media-root media", + "result": "passed", + "summary": "Exported 45 notes, one note type, four templates, and 56 media assets." + }, + { + "command": "BB/target/debug/brainbrew translations --manifest brainbrew.yaml --target de-standard", + "result": "passed", + "summary": "Reported 43 actionable misses, 1,226 hidden fallbacks, and zero stale/invalid keys." + }, + { + "command": "BB/target/debug/brainbrew verify --manifest /tmp/ug-fmt-audit/brainbrew.yaml --target de-standard --translation-coverage strict", + "result": "failed", + "summary": "Strict policy rejected 1,269 untranslated fallbacks." + }, + { + "command": "BB/target/debug/brainbrew workbench serve ...; curl /api/workspace and /api/workbench/note-list", + "result": "partial", + "summary": "Server health passed, but workspace had no languages/profile and German note-list selection returned HTTP 400." + }, + { + "command": "nix run github:jeprecated/brain-brew/rust-brainbrew -- --version", + "result": "failed", + "summary": "Nix package build ran unprepared browser E2E tests; 13 failed because target/debug/brainbrew was absent." + }, + { + "command": "jj status/log/diff in both repositories; diff real UG against BB fixture", + "result": "passed", + "summary": "Confirmed divergent migration/upstream history, working-copy state, and substantial production-fixture drift." + } + ], + "validationOutput": [ + "External HTML/CSS side check: 22 files passed.", + "Target inventory: 74 + 26 = 100.", + "Actual main verify/export: blocked by canonical formatting, then 546 base plus five Experimental blank hashes.", + "Temporary canonical Hardcore verify: all 26 targets passed.", + "Representative Hardcore export: 45 notes and 56 media assets passed.", + "Workbench server health passed but production translation APIs are unconfigured.", + "No source edits were made in UG or Brain Brew." + ], + "residualRisks": [ + "No Anki GUI import smoke test was possible in this non-interactive audit.", + "The old-vs-new evidence generator was inspected but not rerun because it downloads/builds legacy dependencies and writes its report; existing evidence was instead validated against its implementation.", + "All 100 exports were not attempted because the first main export deterministically fails on the same empty-hash gate and CI's Nix tool cannot build.", + "UG migration must still reconcile six upstream-only commits and rerun parity from an immutable baseline." + ], + "noStagedFiles": true, + "notes": "Jujutsu has no staging area. Pre-existing uncommitted audit/frontloop/cache files were observed and left untouched; this task added only the requested audit report." +} +``` diff --git a/audit/14-docs.md b/audit/14-docs.md new file mode 100644 index 0000000..b51cca3 --- /dev/null +++ b/audit/14-docs.md @@ -0,0 +1,198 @@ +## Review + +- Correct: The core product framing is coherent across `README.md:3-18`, `documentation/docs/intro.md:8-23`, and `documentation/docs/reference/project-scope.md:7-30`: local-first federation, no review-state storage, and UG as a general fixture rather than a special product mode. +- Correct: Package/lock functionality is consistently marked experimental in the welcome, scope, authoring, CLI, YAML, and lock reference pages (for example `documentation/docs/intro.md:23` and `documentation/docs/authoring/packages-locking.md:7`). +- Correct: The documented UG workflow is currently accurate. Both commands at `documentation/docs/examples/ultimate-geography.md:48-50` passed with the shown counts (74 and 26), and language-specific variant/translation files do not duplicate `card_templates`; this matches the variable-first/shared-extension guidance at `documentation/docs/examples/ultimate-geography.md:74-90`. +- Correct: The documentation site builds successfully and the repository-local Markdown link check found no missing targets. + +### Ranked gaps + +#### 1. Blocker — The recommended release/version story does not identify an installable binary that has the documented behavior + +`README.md:42-69` and `documentation/docs/getting-started/install.md:9-72` present crates.io, a GitHub Release installer, Homebrew, and a Git tag as current normal install paths. In reality: + +- crates.io `brainbrew 1.0.0-alpha.1` exists; +- the GitHub API returns 404 for both release `v1.0.0-alpha.1` and tag `v1.0.0-alpha.1`, so the shell/PowerShell installer URLs and tag-based Cargo command cannot work; +- `jeprecated/homebrew-tap/Formula` has no `brainbrew.rb`, so `brew install jeprecated/tap/brainbrew` cannot work. + +This also contradicts itself inside the README: it calls the release installer the “easiest no-Rust install path” at `README.md:51-58`, then says Homebrew is available only “once the preview release is published” at `README.md:60-64`. `CHANGELOG.md:20` nevertheless claims that prebuilt archives, installers, and a formula shipped. + +More seriously, the available crates.io alpha is older than the documentation. The downloaded published `brainbrew-1.0.0-alpha.1` source has neither `workbench` nor `media` commands and lacks current options such as `--skip-content-validation` and stale-translation `--resolve`; current docs advertise them at `documentation/docs/reference/cli.md:84-91,130-149`, `documentation/docs/reference/workbench.md:7-31`, and `documentation/docs/authoring/media.md:74-84`. The repository still reports the same alpha version (`Cargo.toml:11-21`) while `CHANGELOG.md:3-10` calls several documented semantics “Unreleased.” A user following the recommended pin therefore gets a different CLI/schema contract than the site describes. + +**Required product/docs decision:** publish/tag the documented build, or version the docs and clearly label checkout-only/unreleased features. Until then, make Cargo alpha the only claimed released channel and remove/disable dead installer, tag, and Homebrew instructions. + +#### 2. Blocker — The zero-to-first-deck quickstart is not executable as written + +The promised first-run path at `documentation/docs/intro.md:34-39` points to `documentation/docs/getting-started/quickstart.md`. Reproducing that page from an empty temporary directory found three onboarding failures: + +1. It asks users to create `overlays/languages/de.yaml` (`quickstart.md:50-61`) without creating its parent directories. +2. `brainbrew compose ... --out build/de-standard.yaml` (`quickstart.md:84-87`) fails with `No such file or directory`; compose writes directly and does not create parent directories (`crates/brain-brew-cli/src/commands/compose.rs:31-34`). +3. After creating `build/`, `brainbrew verify --all-targets` still fails because the pasted manifest and deck are not canonical. The manifest target keys are not formatter order (`quickstart.md:74-79`), and the deck has noncanonical field-map order/quoting/null-sequence spelling (`quickstart.md:23-47`). Verify checks exact canonical formatting before behavior (`crates/brain-brew-cli/src/commands/verify.rs:25-46`). + +The page also says it builds “one target” (`quickstart.md:7`) but defines two (`quickstart.md:74-79`), and teaches `Paris: Paris` as a direct translation (`quickstart.md:57-60`) despite the translation guide reserving reviewed identity text for `translations.no_change` (`documentation/docs/authoring/translations.md:64-88`). + +Add directory creation, use formatter-generated snippets, and execute this page in CI. This is currently the highest-impact onboarding failure after installation. + +#### 3. Blocker — The CLI reference gives an import command that is guaranteed to fail + +`documentation/docs/reference/cli.md:93-99` documents: + +```bash +brainbrew import crowdanki build/crowdanki/en-standard --out deck.yaml +``` + +The implementation rejects any import without `--accept-suggested-ids` (`crates/brain-brew-cli/src/commands/import.rs:15-24`), and the actual built-in help correctly includes the flag (`crates/brain-brew-cli/src/help.rs:54-55`). Executing the reference command exited 1 with `non-interactive CrowdAnki import requires --accept-suggested-ids for now`. + +The reference should use the required flag and explain its trust/review implication next to the command, rather than leaving that explanation only in `documentation/docs/concepts/media.md:59`. + +#### 4. Note (high) — `fmt` include behavior is documented as the opposite of actual behavior + +`documentation/docs/reference/yaml.md:220-222` says formatting a scalar `!include` “materializes the included scalar content into canonical YAML.” The formatter explicitly promises not to materialize includes and restores the directives after canonical ordering (`crates/brain-brew-formats/src/source_includes.rs:46-76`). A representative `brainbrew fmt` run preserved `description: !include content/description.md`. + +This matters because `documentation/docs/authoring/workspace.md:81-87` recommends formatting every source as a review gate. Maintainers cannot tell whether that gate will preserve or destroy their file layout. Update the reference to distinguish read/compose resolution (inlines content in the resolved deck) from source formatting (preserves scalar and media includes). + +#### 5. Note (high) — The “YAML format reference” is not complete enough to author or review the public schema + +`documentation/docs/reference/yaml.md:7` calls itself compact, but it is the only schema reference and omits important accepted/public shapes: + +- Canonical variables at deck, note-type, card-template, and note scopes (`brain-brew-core/src/model.rs:676-685,1089-1127`) are not represented in the deck shape at `reference/yaml.md:9-29`. +- The overlay section lists top-level buckets (`reference/yaml.md:31-47`) but does not specify the sparse schemas for deck, note type, card template, field, tag, adapter-ID, or media changes, nor the `expected_base: entity_present` form used for removals (`brain-brew-core/src/model.rs:982-1085`; emitter at `crates/brain-brew-formats/src/canonical_yaml.rs:1194-1198`). +- The manifest block (`reference/yaml.md:224-244`) omits `package.compatible_base_versions`, `languages`, `translation_profile`, export defaults/goldens, target `extends`, and overlay dependencies, even though these are public fields (`crates/brain-brew-formats/src/manifest.rs:159-220,235-253`). Some are described in authoring prose, but there is no single field/type/default/constraint reference. +- No docs explain stable-ID reserved path grammar, even though public path parsing rejects reserved markers/suffixes (`crates/brain-brew-core/src/model.rs:60-74`). + +This gap is amplified by strict unknown-field rejection. Provide a complete schema table or generated schema/reference, including required/optional/default values and examples for every intent. + +#### 6. Note (high) — Upgrade and recovery guidance does not cover the actual unreleased compatibility changes + +There is no version-to-version migration page despite docs pinning alpha. The only release notes classify changed overlay fill semantics, stricter import rejection, key emission, path-lock behavior, and JSON errors as unreleased (`CHANGELOG.md:3-10`), while the site describes those changes as current. In particular, the new field-operation rule can invalidate existing overlays, but no migration recipe identifies affected syntax or replacement syntax. + +Two implemented recovery paths are also missing or misleading: + +- Stale-translation prose tells users to move/delete records manually (`documentation/docs/authoring/translations.md:163-165`), while the current CLI supports `--resolve confirm|replace`, `--old-source`, `--new-source`, `--stale-context`, and `--translation` (`crates/brain-brew-cli/src/commands/translations.rs:297-330`; built-in help at `crates/brain-brew-cli/src/help.rs:66-67`). The compact CLI reference at `documentation/docs/reference/cli.md:120-128` does not mention them. +- “When upstream changes,” package docs tell users to run `lock verify` first (`documentation/docs/authoring/packages-locking.md:76-86`). For a Git lock this continues verifying the old pinned revision and does not discover branch movement; for a changed path lock it fails on the old hash (`crates/brain-brew-cli/src/commands/lock.rs:94-108,411-454`). Accepting an upstream update requires rerunning `lock update` with the chosen source/ref, reviewing the lock diff, then verifying. That recovery/update sequence is absent. + +Add a versioned migration guide and error-to-recovery table before changing the promised alpha contract. + +#### 7. Note (high) — Workbench documentation describes legacy pivot APIs, not the APIs the browser now uses + +`documentation/docs/reference/workbench.md:50-122` names aggregate endpoints such as `note-pivot`, `source-string-pivot`, and `card-pivot`, but omits the current lazy list/detail endpoints registered at `crates/brain-brew-cli/src/commands/workbench.rs:191-220`: `note-list`, `note-detail`, `card-list`, `source-string-list`, `metadata-list`, and `optional-metadata-list`. The current UI calls these endpoints (`crates/brain-brew-workbench-ui/src/lib.rs:4038-4061,4084-4102,4152-4169`), and the server exposes filter/content-group/status plus `limit`/`offset` contracts with 50 default and 200 maximum (`workbench.rs:1465-1596`). None of those request fields, response shapes, limits, aliases, or API error status rules is documented. + +The page mixes end-user operation, internal development commands, architecture aspirations, and an implicit public JSON API without saying which parts are stable. It also presents Workbench as released (`reference/workbench.md:7-13`) even though the pinned crates.io alpha does not contain the command and the maintained-surface list omits it (`documentation/docs/reference/project-scope.md:9-21`). Mark its maturity explicitly and either document the API contract or label it internal/unstable. + +#### 8. Note (medium) — Package compatibility metadata is public, used by UG, undocumented, and not enforced + +The UG manifest declares `package.compatible_base_versions`, and the format API parses/emits it (`crates/brain-brew-formats/src/manifest.rs:159-166,288-296`), but `rg compatible_base_versions README.md documentation/docs` returns no documentation. Dependency validation only splits `depends_on` at `@` and compares the remainder for literal equality (`crates/brain-brew-cli/src/package_resolver.rs:34-58`); it never consults `compatible_base_versions`. + +That makes a semver-looking value such as `>=0.1,<0.2` metadata with no explained consumer or enforcement. The experimental labels are good, but experimental users still need accurate semantics. Document it as inert metadata or implement/describe enforcement; also state that `depends_on` currently accepts exact literal versions rather than ranges. + +#### 9. Note (medium) — README promises an edit/export onboarding loop that does not exist + +`README.md:79` explicitly sends users to `getting-started/install.md` for “an edit/export loop.” That page ends after install, Nix, developer shell, and runtime dependency sections (`documentation/docs/getting-started/install.md:80-126`) and contains no workspace checkout, edit, verify, or export exercise. Combined with the broken quickstart, there is no tested path from installed binary to editing an existing deck and inspecting/importing the exported result. + +Add either the promised loop (preferably against `fixtures/ug-style`) or remove the claim and link directly to a tested quickstart. + +#### 10. Note (medium) — Public Rust crates are advertised as reusable without consumer documentation + +The README calls `brain-brew-core` and `brain-brew-formats` reusable (`README.md:9-11,83-87`), and both are published packages, but the site has no Rust dependency/usage examples for `CanonicalDeck`, validation/composition, YAML codecs, CrowdAnki import/export, or include context. The only library symbol named in user docs is `import_deck_accept_suggested_ids` at `documentation/docs/concepts/media.md:59`. The modules are public (`crates/brain-brew-formats/src/lib.rs:7-14`) and core exports its entire model (`crates/brain-brew-core/src/lib.rs:10-12`), so this is undocumented public behavior, not merely CLI detail. + +At minimum, link docs.rs and provide one supported API example plus a stability statement; otherwise describe these crates as internal implementation packages rather than a supported reusable surface. + +### Residual observations + +- `plan.md` and `progress.md`, requested as initial inputs, do not exist in the working directory; this did not block inspection. +- No broken local Markdown links were found, and `npm run build` passed. Docusaurus validates presentation/links but does not execute command snippets, which is why the quickstart and import regressions escaped. +- `brainbrew media images-to-refs` is a useful migration command and is documented at `documentation/docs/authoring/media.md:74-84`, but it rewrites files in place with no dry-run (`crates/brain-brew-cli/src/commands/media.rs:89-116`). Migration guidance should recommend a clean VCS state and review of the resulting diff. + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Performed a review-only documentation/product audit and wrote only audit/14-docs.md; no product or documentation source was edited." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Findings include exact documentation and implementation references plus reproduced command output for install availability, quickstart failures, import failure, include formatting, docs build, and both UG verification workflows." + } + ], + "changedFiles": [ + "audit/14-docs.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "jj status && jj diff --stat", + "result": "passed", + "summary": "Confirmed the pre-existing audit files and later this review output; no product source edits were made." + }, + { + "command": "target/debug/brainbrew targets/compose/verify against a /tmp workspace copied from getting-started/quickstart.md", + "result": "failed", + "summary": "Targets listed, then compose failed because build/ did not exist; after creating it, verify failed on noncanonical manifest/deck snippets." + }, + { + "command": "target/debug/brainbrew import crowdanki fixtures/ug-style/goldens/en-standard --out /tmp/doc-import.yaml", + "result": "failed", + "summary": "Exited 1 with the documented command because --accept-suggested-ids is mandatory." + }, + { + "command": "target/debug/brainbrew fmt /tmp/brainbrew-doc-audit-include/deck.yaml", + "result": "passed", + "summary": "Formatting preserved description: !include, disproving reference/yaml.md's materialization claim." + }, + { + "command": "target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets", + "result": "passed", + "summary": "Verified 74 targets, matching the UG docs." + }, + { + "command": "target/debug/brainbrew verify --manifest fixtures/ultimate-geography/brainbrew-hardcore.yaml --all-targets", + "result": "passed", + "summary": "Verified 26 targets, matching the Hardcore companion docs." + }, + { + "command": "cd documentation && npm run build", + "result": "passed", + "summary": "Docusaurus production build completed successfully." + }, + { + "command": "Python local Markdown-link scan over documentation/docs/**/*.md", + "result": "passed", + "summary": "No missing repository-local Markdown targets found." + }, + { + "command": "GitHub/crates.io/Homebrew HTTP API checks for v1.0.0-alpha.1", + "result": "failed", + "summary": "crates.io package exists, but GitHub release/tag and installer asset return 404 and the Homebrew tap has no brainbrew formula." + }, + { + "command": "download and inspect static.crates.io brainbrew-1.0.0-alpha.1.crate", + "result": "passed", + "summary": "Published alpha help lacks current documented workbench/media commands and newer options." + }, + { + "command": "cargo doc --workspace --no-deps", + "result": "passed", + "summary": "Workspace Rust documentation generated successfully; this does not supply end-user API examples or a stability contract." + } + ], + "validationOutput": [ + "Quickstart compose: build/de-standard.yaml: No such file or directory (os error 2)", + "Quickstart verify after mkdir: brainbrew.yaml is not in canonical format; after formatting manifest: ./deck.yaml is not in canonical format", + "Import reference: non-interactive CrowdAnki import requires --accept-suggested-ids for now", + "UG main: verified 74 targets", + "UG Hardcore: verified 26 targets", + "Docusaurus: Generated static files in build", + "Release checks: crates.io alpha HTTP 200; GitHub release/tag/installer HTTP 404; no Homebrew brainbrew.rb" + ], + "residualRisks": [ + "External release-channel availability was checked live on 2026-07-09 and can change after this audit.", + "The full Workbench browser E2E suite was not rerun; this review compared docs to routes, query structs, and current UI call sites.", + "No real remote package lock was updated because that would mutate source/lock state; lock recovery findings are based on verified implementation control flow." + ], + "noStagedFiles": true, + "notes": "Review only. plan.md and progress.md were requested but absent. Other audit/*.md working-copy additions predated this task and were not modified." +} +``` diff --git a/audit/15-release-security.md b/audit/15-release-security.md new file mode 100644 index 0000000..e6b2f77 --- /dev/null +++ b/audit/15-release-security.md @@ -0,0 +1,245 @@ +# Release, Supply-Chain, and Filesystem/Network Security Audit + +## Review + +### Correct + +- Cargo forbids unsafe Rust workspace-wide (`Cargo.toml:23-24`), and the committed `Cargo.lock` contains only checksum-backed crates.io registry sources (364 sourced packages; no Git dependencies were found). +- Both Nix inputs are locked by revision and NAR hash (`flake.lock`; `devenv.lock`), and the separate cargo-dist Nix input is also revision-pinned (`devenv.yaml:4-5`). `nix flake check --no-build` evaluated successfully for the host system and `nix flake show --all-systems` exposed the four declared Nix systems. +- Normal CI explicitly limits `GITHUB_TOKEN` to `contents: read` (`.github/workflows/ci.yml:7-8`), runs formatting, Rust tests, clippy with warnings denied, embedded-asset freshness, and browser E2E (`devenv.nix:108-115`). +- Embedded Workbench filenames are content-hashed and `index.html` has SRI for CSS, JS, and WASM (`crates/brain-brew-cli/assets/workbench/index.html:7-9`). `devenv shell workbench-ui-embed-check` rebuilt the release bundle and reported `Workbench embedded release assets are fresh.` The CLI crate package list includes all four assets. +- The release smoke command installs with the lockfile and exercises version, validate, compose, CrowdAnki export, and verify (`devenv.nix:100-106`; `scripts/release_smoke.sh:1-31`). It passed locally. +- The Workbench binds only to IPv4 loopback (`crates/brain-brew-cli/src/commands/workbench.rs:162-170`), media paths reject lexical parent traversal (`crates/brain-brew-cli/src/media_assets.rs:67-77`), and browser launch passes the fixed local URL as a process argument rather than interpolating it into a shell (`crates/brain-brew-cli/src/commands/workbench.rs:4962-4983`). +- A tracked-file scan found no private-key markers, GitHub PAT-like values, AWS access-key IDs, or tracked `.env`/credential files. The largest intentional embedded asset is the 1,078,375-byte WASM file. + +## Severity-ranked findings + +### Blocker — the current workspace version is already immutable on crates.io and the dependent package no longer builds against that published version + +**Refs:** `Cargo.toml:11-21`; `scripts/publish_crates.sh:53-60,106-122`; `.github/workflows/package-smoke.yml:14-21`; `devenv.nix:99-105`; `documentation/docs/getting-started/install.md:11-30`. + +All publishable crates still declare `1.0.0-alpha.1`, with exact internal requirements `=1.0.0-alpha.1`. The crates.io API and `cargo search` confirm that `brain-brew-core`, `brain-brew-formats`, and `brainbrew` at that version already exist and are not yanked. Those immutable published sources predate current workspace APIs. + +**Concrete validation:** `devenv shell release:crates` failed. `brain-brew-core` dry-run warned that `brain-brew-core@1.0.0-alpha.1 already exists`; then `brain-brew-formats` package verification downloaded that published core and failed with 40 compile errors, including missing `FieldImageReference`, `MessageComponent`, `StaleTranslation`, `StructuredMessage`, and `TargetAdaptation`. Current path-based workspace tests cannot expose this. The job named “Install packaged CLI” also does not install a `.crate`; it executes `cargo install --path ...` (`.github/workflows/package-smoke.yml:16-19`) and therefore uses current local path dependencies. + +**Required resolution:** bump all three publishable crates and exact internal requirements to a new, unpublished version; update lockfile, changelog, docs, and the hard-coded dist plan tag; then verify actual packaged artifacts in dependency order against the registry state. The package smoke gate must install/test extracted `.crate` contents (or a staging registry), not only workspace paths. No release should proceed while this gate fails. + +### High — Workbench trusts arbitrary Host/Origin values, leaving its unauthenticated read/write API open to DNS rebinding + +**Refs:** `crates/brain-brew-cli/src/commands/workbench.rs:162-188,191-222`. + +Loopback binding alone does not establish a browser security boundary. The router exposes workspace/source reads and mutation endpoints (`new-language`, `apply-preview`, and `apply`) with no session token, Host allowlist, or Origin validation. A malicious origin that DNS-rebinds its hostname to `127.0.0.1` remains same-origin in the browser and can reach these endpoints; the server currently accepts the attacker-controlled Host. + +**Concrete validation:** against a live Workbench on an ephemeral loopback port, both a normal request and this request returned HTTP 200: + +```text +Host: attacker.example +Origin: https://attacker.example +GET /api/health +``` + +The attacker-host response disclosed `{"manifest":"fixtures/ug-style/brainbrew.yaml","status":"ok"}`. The same router hosts file-mutating POST handlers. + +**Required resolution:** generate an unguessable per-process capability and require it for every API route (especially mutations), validate Host against the exact loopback authority/port, reject foreign Origin/Referer values, and add DNS-rebinding tests for both reads and writes. Keep loopback binding as defense in depth. + +### High — release jobs execute mutable/unverified third-party code with publication credentials + +**Refs:** `.github/workflows/release.yml:17-18,56-72,112-145,222-279,286-325`; `.github/workflows/ci.yml:16-24`; `.github/workflows/package-smoke.yml:12-18`. + +Every GitHub Action is referenced by a mutable major tag (`actions/checkout@v6`, artifact actions, Cachix actions, and `dtolnay/rust-toolchain@stable`) rather than a commit SHA. The release workflow grants `contents: write` at workflow scope, places `GH_TOKEN` in build/plan job environments, downloads the cargo-dist installer with `curl | sh` without checking a digest/signature, may install rustup the same way, and executes `matrix.install_dist.run`/`matrix.packages_install` generated by that tool. The Homebrew job checks out with a long-lived tap token and also invokes mutable actions. + +A compromise or retag of any of these upstreams can replace release artifacts, create GitHub releases, or steal the tap token. Pinning `cargo-dist-version = "0.30.4"` (`dist-workspace.toml:5-7`) identifies a version but does not authenticate the downloaded installer bytes. + +**Required resolution:** pin every action to a reviewed full commit SHA; verify cargo-dist/rustup installers against published hashes or signatures (prefer a prebuilt, pinned toolchain image/Nix derivation); move `contents: write` to the host job only; keep build jobs read-only and token-free; protect publication with a GitHub Environment and approval; use a narrowly scoped/rotated Homebrew credential. + +### High — a tag can publish without the repository quality gates, crate publication, or any pre-tag macOS/Windows build + +**Refs:** `.github/workflows/release.yml:41-55,90-166,215-279,281-343`; `.github/workflows/ci.yml:3-33`; `.github/workflows/package-smoke.yml:3-21`; `dist-workspace.toml:10-18`; `CHANGELOG.md:20-21`. + +The release workflow is independent of CI and Package Smoke. Its host job depends only on cargo-dist plan/build jobs, so a tag release can be created while normal CI fails or is still running. The workflow contains no Rust test, clippy, embedded freshness, E2E, release smoke, crates metadata, or crate dry-run invocation. It publishes GitHub artifacts and Homebrew only; `scripts/publish_crates.sh` is not called, despite the changelog claiming crates.io publication. + +`dist manifest` reported `pr_run_mode=plan`, so release pull requests do not build artifacts. The macOS ARM, macOS Intel, Windows, and Linux release binaries are therefore first built only after the publication tag is pushed. Normal CI and Package Smoke both run only on Ubuntu. The configured release matrix also omits `aarch64-unknown-linux-*`, although the Nix flake advertises `aarch64-linux` (`flake.nix:11-16`; `dist-workspace.toml:12-13`). + +**Required resolution:** make release call a reusable, passing quality workflow before host/publish; build and smoke-test every release target on pull requests or an explicit release-candidate workflow; include crate package verification/publication in the versioned release transaction (with restart/idempotency design); and document or add ARM Linux support. Add artifact signature/provenance/SBOM generation before publication. + +### High — the advertised Nix build/install path is broken because the package check runs browser E2E without its harness + +**Refs:** `flake.nix:25-41,72-75`; `devenv.nix:53-82`; `documentation/docs/getting-started/install.md:80-110`. + +The Nix derivation sets `cargoTestFlags = [ "--workspace" "--all-targets" ]`, which includes `brain-brew-workbench-e2e`, but does not build the expected debug CLI/UI or provide Chromium/chromedriver and E2E environment variables. `checks` is merely an alias of this same package derivation, not a separate quality check. + +**Concrete validation:** `nix build .#checks.x86_64-linux.brainbrew -L --no-link` failed all 13 Workbench E2E tests with `brainbrew test binary not found at /build/source/target/debug/brainbrew; run devenv shell e2e`. Because `nix build .#brainbrew`, `nix run`, and profile install use the same derivation, this contradicts the documented install commands. + +**Required resolution:** exclude the E2E crate from the normal Nix package check as the documented Cargo gates do, and define a separate Nix E2E check with all browser/UI/runtime inputs. Add explicit fmt/clippy/unit checks rather than aliasing the package derivation. Build all four declared systems in CI or a trusted binary cache workflow. + +### High — fetched package archives preserve symlinks that can escape the hashed cache and package root + +**Refs:** `crates/brain-brew-cli/src/commands/lock.rs:43-49,481-555,639-651,786-813`; subsequent locked-manifest join at `:94-108` and `:457-476`. + +Tarballs are unpacked, copied, NAR-hashed, and cached, but `copy_dir_contents` deliberately recreates symlinks. Later file access joins manifest/base/include/media paths lexically and follows those symlinks. The NAR hash authenticates the symlink text, not confinement of the dereferenced target. A fetched package can therefore cause Brain Brew to read host files outside its source tree (and filesystem-facing Workbench/export flows may serve or copy such content). + +**Concrete validation:** a temporary tarball contained `pkg/brainbrew.yaml` as an absolute symlink to a manifest outside the archive. This command succeeded: + +```text +brainbrew lock update --package audit.tar-symlink --tarball file://.../pkg.tar +exit=0 +``` + +The cache retained `.../sources//brainbrew.yaml -> /tmp/.../outside.yaml`, and the external package metadata was accepted into the lock. + +**Required resolution:** reject symlinks in fetched package sources, or canonicalize every dereferenced source path and require it to stay beneath the canonical package root. Apply containment to manifests, bases, overlays, scalar/media includes, media reads, and Workbench writes. Add tarball tests for absolute links, relative `../` links, link chains, and links swapped between validation and use. + +### Medium — lock fetching has no transport policy, timeout/size budget, or decompression budget + +**Refs:** `crates/brain-brew-cli/src/commands/lock.rs:493-537,604-651,675-686`. + +Arbitrary tarball URLs flow directly to `ureq`; HTTP is not rejected, and even GitHub repository parsing explicitly accepts `http://`. Responses are read completely into an unbounded `Vec`, GitHub JSON into an unbounded `String`, and gzip/tar extraction has no entry-count, per-file, expanded-size, or compression-ratio limit. The first `lock update` computes its hash only after download and extraction, so hash locking does not protect that initial fetch from insecure transport or resource exhaustion. + +**Required resolution:** allow HTTPS network URLs only (retain an explicit local-file mode), configure connect/read/overall timeouts and redirect policy, enforce compressed and expanded byte/entry limits while streaming, reject special archive entries, and fail before cache mutation. Tests should use a local server for slow responses, redirect loops, oversized `Content-Length`, chunked over-limit bodies, and gzip bombs. + +### Medium — no dependency/advisory/license gate exists, and the committed documentation graph currently has a high advisory + +**Refs:** `devenv.nix:84-86,108-122`; `.github/workflows/ci.yml:23-26`; `documentation/package.json:11-20`; `documentation/package-lock.json:7080,7391,10268-10275,16284-16291`; `Cargo.lock:2364-2367`. + +The quality gate has no `cargo audit`, `cargo deny`, OSV, `npm audit`, license allowlist, dependency updater, SBOM, or provenance check. Documentation installation uses `npm install`, not deterministic `npm ci`, and docs are not built in CI. The lock contains 369 Cargo packages (364 registry sources) and 1,281 npm package entries, so manual review is not a realistic substitute. + +**Concrete validation:** `npm --prefix documentation audit --omit=dev --json` reported 26 vulnerabilities: 1 high, 24 moderate, and 1 low. The high finding is `serialize-javascript` 6.0.2 (`GHSA-5c6j-r48x-rmvq`); the lock also contains vulnerable `js-yaml` 4.1.1 according to the audit. `cargo-audit` and `cargo-deny` were not installed and no CI references were found. Rust also directly uses the deprecated `serde_yaml 0.9.34+deprecated`, although this audit did not establish a RustSec vulnerability for it. + +**Required resolution:** use `npm ci`, build docs, remediate or explicitly time-bound advisory exceptions, and gate npm/Cargo advisories and licenses in CI. Add automated dependency updates and generate an SBOM plus GitHub artifact attestations for releases. + +### Low — published crate archives omit the project license text and README/package-discovery metadata + +**Refs:** `Cargo.toml:11-17`; each publishable crate manifest `crates/brain-brew-{core,formats,cli}/Cargo.toml:1-11`; root `LICENSE`; `scripts/check_cratesio_metadata.py:31-66`. + +The SPDX expression and repository metadata are present, but `cargo package --list` showed no `LICENSE` or `README` in any of the three publishable archives. Cargo metadata also reported `readme=None`, empty keywords, and empty categories for all three. The metadata checker only requires description/license/repository and therefore passes this incomplete packaging. + +**Required resolution:** include the canonical license text and an appropriate README in every `.crate`; add useful keywords/categories; and make the metadata/package-content check assert those files before publishing. + +## Notes and residual risks + +- `plan.md` and `progress.md` were requested but absent at the supplied paths, so no plan/progress-specific assumptions could be checked. +- No Rust advisory result is claimed: `cargo-audit` was unavailable and is not configured in the repository. The absence of a result is itself covered by the missing-gate finding. +- `nix flake check --no-build` validates evaluation only. The subsequent real Nix build was run and failed as documented above. +- Package archive lists were inspected, but dependent `.crate` verification cannot currently pass until the version blocker is fixed. +- No source, test, workflow, dependency, or configuration file was edited. This report is the only file created by this audit. +- Review gate: **failed / blockers present**. Do not publish from the current version or workflow state. + +## Validation summary + +| Command/probe | Result | Evidence | +|---|---:|---| +| `jj status` / `jj diff --summary` | Passed | Existing uncommitted audit reports only before this report; no source/config changes and Jujutsu has no staged index. | +| `devenv shell crates:metadata-check` | Passed | Metadata checker reported all three crates at `1.0.0-alpha.1`. | +| `devenv shell release:smoke` | Passed | Locked path install completed; version, validate, compose, export, and verify succeeded. | +| `devenv shell release:crates` | **Failed** | Dependent crate verification compiled against immutable published core and produced 40 API mismatch errors. | +| `cargo package -p --list --allow-dirty` | Passed with finding | 17/23/37 files; embedded assets present; no LICENSE/README in any publishable crate. | +| `devenv shell workbench-ui-embed-check` | Passed | Release Trunk output matched checked-in assets byte-for-byte. | +| `nix flake check --no-build` | Passed with limitation | Host evaluation succeeded; incompatible systems were omitted from builds. | +| `nix flake show --all-systems --json` | Passed | Four declared package/app/check systems evaluated. | +| `nix build .#checks.x86_64-linux.brainbrew -L --no-link` | **Failed** | 13/13 E2E tests failed because the Nix derivation lacks the required debug CLI/E2E harness. | +| `devenv shell dist:plan` | Passed with findings | Release `v1.0.0-alpha.1`; PR mode is `plan`; four release targets build only in publication mode. | +| `npm --prefix documentation audit --omit=dev --json` | **Failed policy-wise** | 1 high, 24 moderate, 1 low advisory. | +| `bash -n scripts/*.sh` and Python compile check | Passed | Release/helper scripts parsed. Temporary Python bytecode was removed immediately and is not a repository change. | +| lock tarball symlink probe | **Security reproduction succeeded** | External symlinked manifest was read and retained in the hash-addressed cache. | +| Workbench Host/Origin probe | **Security reproduction succeeded** | Foreign Host and Origin received HTTP 200 and workspace manifest metadata. | +| tracked secret-marker scan | Passed | No private key, PAT-like token, or AWS access-key marker found. | + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Created only audit/15-release-security.md and reviewed Cargo/devenv/Nix, dependency features and locks, all GitHub workflows, cargo-dist and crate publishing scripts, package archives/metadata/licensing, embedded assets, and filesystem/network boundaries without changing implementation files." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "Each finding cites exact files/lines and includes concrete command output or a reproducible probe; validation includes the failing crate dry-run, failing real Nix build, npm advisory counts, Host/Origin acceptance, and tarball symlink escape." + } + ], + "changedFiles": [ + "audit/15-release-security.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "devenv shell crates:metadata-check && devenv shell release:smoke", + "result": "passed", + "summary": "Metadata passed and the locked path-installed CLI completed version/validate/compose/export/verify smoke coverage." + }, + { + "command": "devenv shell release:crates", + "result": "failed", + "summary": "Confirmed the release blocker: formats verification downloaded already-published core 1.0.0-alpha.1 and failed with 40 API mismatch errors." + }, + { + "command": "cargo package -p --list --allow-dirty", + "result": "passed", + "summary": "Inspected exact crate payloads; CLI assets are present, but all publishable archives omit LICENSE and README." + }, + { + "command": "devenv shell workbench-ui-embed-check", + "result": "passed", + "summary": "Checked-in release JS/WASM/CSS/HTML matched a fresh release build byte-for-byte." + }, + { + "command": "nix flake check --no-build && nix flake show --all-systems --json", + "result": "passed", + "summary": "Flake evaluated and exposed all four declared systems." + }, + { + "command": "nix build .#checks.x86_64-linux.brainbrew -L --no-link", + "result": "failed", + "summary": "Real Nix package/check build failed all 13 browser tests because the derivation does not provision the E2E harness." + }, + { + "command": "devenv shell dist:plan", + "result": "passed", + "summary": "Generated a 14-artifact plan and confirmed PR mode is plan-only with four publication targets." + }, + { + "command": "npm --prefix documentation audit --omit=dev --json", + "result": "failed", + "summary": "Reported 26 advisories: 1 high, 24 moderate, and 1 low." + }, + { + "command": "temporary tarball symlink lock-update probe", + "result": "passed", + "summary": "Reproduced the vulnerability: lock update accepted an external absolute symlink manifest and preserved it in cache." + }, + { + "command": "temporary live Workbench foreign Host/Origin probe", + "result": "passed", + "summary": "Reproduced DNS-rebinding exposure: attacker.example Host/Origin received HTTP 200 and manifest metadata." + }, + { + "command": "bash -n scripts/*.sh; Python syntax compile; cargo metadata --locked", + "result": "passed", + "summary": "Shell/Python helper syntax and locked Cargo metadata validated; generated bytecode was cleaned immediately." + }, + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Confirmed no staged index and no implementation/config changes; only requested audit report files are uncommitted." + } + ], + "validationOutput": [ + "release:crates: 40 compilation errors against already-published brain-brew-core 1.0.0-alpha.1.", + "Nix build: 0 passed / 13 failed Workbench E2E tests due to missing target/debug/brainbrew.", + "Workbench probe: normal=200, host_attacker=200; response disclosed the manifest path.", + "Tarball probe: exit=0 and cached brainbrew.yaml remained an absolute symlink outside the package/cache.", + "npm audit: high=1, moderate=24, low=1, total=26.", + "Embedded assets: Workbench embedded release assets are fresh.", + "Tracked secret-marker scan: no matches." + ], + "residualRisks": [ + "plan.md and progress.md were absent at the requested paths.", + "No RustSec result is available because cargo-audit is neither installed nor configured in CI.", + "Non-host Nix systems were evaluated but not built, and release macOS/Windows artifacts were not available for local smoke testing.", + "Actual dependent crate artifact verification remains blocked until the immutable-version collision is resolved." + ], + "noStagedFiles": true, + "notes": "Review only: no implementation changes or tests were added. Review gate fails because a release blocker and multiple high-severity security/release issues are verified." +} +``` diff --git a/audit/16-synthesis.md b/audit/16-synthesis.md new file mode 100644 index 0000000..fb5744f --- /dev/null +++ b/audit/16-synthesis.md @@ -0,0 +1,592 @@ +# Brain Brew audit synthesis + +## Executive assessment + +Brain Brew has a coherent domain model, deterministic happy-path composition, unusually broad integration coverage, and a credible local-first architecture. The primary crate direction is sound (`brain-brew-core` → `brain-brew-formats` → `brainbrew`), the maintained fixture composes 74 main and 26 Hardcore targets, and the default test and browser gates are green. + +It is **not release-ready or safe to describe as fail-closed**. The combined audit found independently reproduced paths for silent source loss, stale or partial Workbench writes, lock inputs escaping their authenticated tree, incomplete destructive-change checks, and publication from an immutable already-used version. The production Ultimate Geography checkout—the first consumer—is not the same state as the fixture and cannot currently pass its documented release gate. + +### Bottom line + +- **Strengths:** deterministic ordered models/emitters; strict rejection of many unsupported CrowdAnki fields; broad command/API/browser coverage; real 74+26 target fixture; loopback Workbench; content-addressed cache validation when hashes are present; fresh embedded Workbench assets. +- **Release blockers:** immutable crates.io version collision; broken Nix package path; mutable/unhashed package escape paths; silent YAML duplicate/union loss; unsafe Workbench write/conflict behavior; production UG noncanonical and unhashed; no mandatory independent release oracle. +- **Confidence caveat:** 404 non-browser tests and 13 browser tests pass, but several central oracles are incomplete or optional. Green tests prove the maintained fixture and happy paths, not production UG readiness, full round-trip equivalence, or adversarial filesystem safety. +- **Recommended posture:** freeze release claims and mutating-workflow expansion; first restore source/integrity invariants and package/release gates, then complete product workflows, then optimize/deepen architecture. + +### Reconciled facts and audit disagreements + +1. **Fixture media:** `fixtures/ultimate-geography/media.yaml:1-9` has empty `sha256` values. The fixture is canonical and all 74 targets verify *without* `--media-root`, because that path checks references but not hash presence. It is therefore incorrect to infer asset integrity from the green fixture verification. `crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:258-328` proves a 546-entry hoisted map and semantic equivalence to inline declarations, not non-empty hashes. +2. **Fixture versus production:** the checked-in fixture is a migrated future/derivative state with structured `!image`, hoisted `media.yaml`, and a language/profile catalog. The live checkout at `/home/jmo/Development/external/ultimate-geography` remains noncanonical, has inline/blank-hash media, and lacks the language catalog. Fixture success is not production-consumer acceptance. +3. **Test count:** the maintained default gate lists and passes **404 non-browser tests**. The WebDriver executable has **13 test cases**; those cases contain many sub-workflows, which explains artifacts describing a larger workflow count. +4. **Media validation wording:** `verify` always checks reference consistency, but only `--media-root` invokes byte/hash validation (`crates/brain-brew-cli/src/commands/verify.rs:47-79`; `crates/brain-brew-cli/src/media_assets.rs:8-45`). Export is weaker: without `--media-root`, it skips the reference validator as well (`crates/brain-brew-cli/src/commands/export.rs:86-100`). +5. **External checkout availability:** one CLI artifact reported no external checkout in its local probe, while the flow-map and dedicated consumer audit inspected `/home/jmo/Development/external/ultimate-geography`. The dedicated consumer evidence is more specific and is used here. +6. **Working-copy status:** individual artifacts differ in whether they list their allowed report as a changed file. Current `jj status` shows only `audit/01-flow-map.md` through `audit/15-release-security.md` before this synthesis; no product/source file is modified. + +## End-to-end flows + +### 1. Maintainer source → canonical in-memory deck + +```text +Canonical deck YAML + scalar/media !include files + -> CLI read and include resolution + -> strict format decoder + -> CanonicalDeck validation +``` + +- Entry points: `crates/brain-brew-cli/src/io.rs:47-96`. +- Canonical codecs and deterministic emitters: `crates/brain-brew-formats/src/canonical_yaml.rs:23-182`. +- Include constraints/resolution: `crates/brain-brew-formats/src/source_includes.rs:13-78,321-566`. +- Domain validation: `crates/brain-brew-core/src/validate.rs:12-227`. + +The intended boundary is clean at the top level, but source mutation is fragmented: Workbench, media, and translation commands each manipulate YAML in CLI code. Dynamic-map duplicate keys and malformed union branches are not fail-closed, and conversion errors frequently lose file/schema location. + +### 2. Manifest/target/package → ordered composition plan + +```text +root manifest + -> ManifestRegistry::load + -> explicit includes / package-root discovery / sibling lock packages + -> target extends expansion + -> overlay dependency expansion and de-duplication + -> base and ordered overlay reads + -> ManifestTargetPlan +``` + +- Package-aware spine: `crates/brain-brew-cli/src/io.rs:151-180,196-435`. +- Local-only duplicate expansion engine: `crates/brain-brew-formats/src/manifest.rs:22-151`. +- Package dependency checks: `crates/brain-brew-cli/src/package_resolver.rs:9-61,64-93`. + +Ordering and cycle checks are deterministic for the paths they cover. However, dependency validation is bypassed for explicit `--include`, package-qualified `targets --json` uses the wrong local expansion path, catalog ID/kind is not checked against loaded overlay content, and package/base/overlay paths lack package-root containment. + +### 3. Base + overlays → composed target + +```text +clone base + -> resolve structured messages + -> for each ordered overlay: + translation dictionary + deck metadata + note types/templates/fields + notes/field fills + media + resolve messages + -> final validation +``` + +- Main algorithm: `crates/brain-brew-core/src/compose.rs:10-43,76-151`. +- Conflict attribution and override rules: `compose.rs:1594-1640`. +- Structured messages: `crates/brain-brew-core/src/messages.rs:5-196`. + +The standard intent/conflict matrix is well tested, but complete entity replacement often checks only that an expected base exists, structured image/message values are not treated as semantic field values during blank checks, flat tombstones alias entity kinds, and message references are resolved from one stale snapshot. + +### 4. Translation extraction/application → localized target + +```text +incremental deck at translation overlay + -> extract source units/context + -> precedence: adaptation / ignored / variable / contextual / direct / + no-change / stale / fallback + -> coverage report + -> apply dictionary + -> continue composition +``` + +- Extraction and precedence: `crates/brain-brew-core/src/translation.rs:28-157,240-310`. +- CLI reporting/rewrite: `crates/brain-brew-cli/src/commands/translations.rs:23-121,975-1335,2682-2920`. +- Workbench mutation has a second policy implementation: `crates/brain-brew-cli/src/commands/workbench.rs:4243-4393,4682-4795`. + +Stale/direct/contextual behavior is mostly explicit. Major gaps are field-name translation without Mustache rewrite/validation, strict coverage evaluated independently per split overlay, blank direct translations acting as implicit global deletion, and UG-specific hidden `target_additions` syntax. + +### 5. Verify → release-readiness report + +```text +canonical root source checks + -> target planning + -> incremental translation policy + -> composition + validation + -> media reference checks + -> optional media bytes/hash checks + -> render variables + HTML/CSS checks + -> optional CrowdAnki golden comparison +``` + +- Command: `crates/brain-brew-cli/src/commands/verify.rs:18-90,94-350`. +- Exact CrowdAnki comparison: `crates/brain-brew-formats/src/crowdanki.rs:92-568`. + +This is conceptually the right release gate. It is weakened by optional media-byte validation, optional and absent UG goldens, root-biased canonical checks, repeated parsing/composition, and a strict translation policy that cannot model split overlay responsibility. + +### 6. CrowdAnki import → canonical source bootstrap + +```text +folder/deck.json + -> deny-unknown strict JSON + -> reject unsupported adapter fields/defaults + -> derive suggested stable IDs + -> preserve model UUID/note GUID adapter IDs + -> media declarations with empty hashes + -> optional strict whole-field image conversion + -> validate + -> write canonical YAML +``` + +- CLI: `crates/brain-brew-cli/src/commands/import.rs:8-37`. +- Adapter: `crates/brain-brew-formats/src/crowdanki.rs:788-925,936-1218`. + +This is a bootstrap, not a safe round-trip workflow. It requires blanket `--accept-suggested-ids`, has no review/override artifact, ASCII-only note slugging collides on non-Latin content, accepts duplicate GUIDs, normalizes malformed template ordinals, imports no media bytes, and overwrites output without `--force` or atomic replacement. + +### 7. Composed target → CrowdAnki export/media folder + +```text +compose + -> variable/message/image lowering + -> adapter validation/defaults/identity + -> deterministic deck.json + -> optional asset validation and declared-file copy +``` + +- Export adapter: `crates/brain-brew-formats/src/crowdanki.rs:13-82,571-764`. +- CLI/output: `crates/brain-brew-cli/src/commands/export.rs:17-120`. +- Asset handling: `crates/brain-brew-cli/src/media_assets.rs:8-76`. + +Ordering is deterministic and unsupported adapter state is broadly rejected. Without `--media-root`, export may accept undeclared raw references and emits no media. Existing output directories are reused, stale files survive, and a copy failure can leave a mixed old/new tree. + +### 8. Package source → lock/cache → federated plan + +```text +path / GitHub git / tarball + -> snapshot/filter/extract + -> NAR SHA-256 + -> content-addressed cache + -> package metadata validation + -> deterministic lock YAML + -> planner auto-loads sibling lock packages +``` + +- Update/verify/fetch/cache: `crates/brain-brew-cli/src/commands/lock.rs:25-120,321-601,675-845,924-970`. +- Lock schema: `crates/brain-brew-formats/src/lockfile.rs:12-42,148-229`. + +Warm-cache rehashing is a good invariant. It is defeated by optional hashes and by manifests/bases/overlays or symlinks escaping the hashed tree. Network fetches also lack transport, timeout, download, and decompression budgets. + +### 9. Workbench browser → staged drafts → source write + +```text +loopback server + embedded/dev Leptos UI + -> workspace/language selection + -> paginated list + selected detail/pivot + -> localStorage drafts + -> apply preview / apply + -> source and translation mutation + -> temp files + sequential renames + -> cache invalidation/refresh +``` + +- Server/routes: `crates/brain-brew-cli/src/commands/workbench.rs:156-231`. +- Selection/cache: `workbench.rs:464-599,1162-1360`. +- Apply: `workbench.rs:878-1064,3878-4017,4050-4937`. +- UI state/staging: `crates/brain-brew-workbench-ui/src/lib.rs:24-298,1629-1694,3873-4196`; `staging.rs:33-146`. + +The real browser happy paths are broad. Safety and completeness are not: fingerprints are informational, preview is bypassable/unbound, multi-file writes can partially commit, include-bearing deck edits can become noncanonical, off-screen staged scopes are omitted, rows after 50 are unreachable, and compatibility pivots still return broad payloads. + +### 10. Tag/version → crates/Nix/binaries → consumer release + +```text +workspace version + -> crate package verification/publication + -> cargo-dist plan/build + -> GitHub release/Homebrew + -> consumer pins tool + -> consumer verify/export/package +``` + +- Version/package metadata: `Cargo.toml:11-21`. +- Crate publisher: `scripts/publish_crates.sh:53-60,106-122`. +- Nix derivation: `flake.nix:25-41,72-75`. +- Release workflow: `.github/workflows/release.yml:41-166,215-343`. + +The local path release smoke passes, but the actual packaged dependency chain does not: `1.0.0-alpha.1` already exists with older APIs. Nix includes E2E without preparing its harness. Release jobs are not gated on CI/package smoke and execute mutable third-party action/installer inputs with publication credentials. + +## Critical findings + +### C1. Canonical YAML can silently lose accepted maintainer data + +- Dynamic maps deserialize directly into `BTreeMap`, so duplicate IDs/fields/targets/packages/media entries overwrite earlier values (`crates/brain-brew-formats/src/canonical_yaml.rs:1406-1428,2122-2135,2316-2325`; `manifest.rs:624-639`; `lockfile.rs:148-154`; `media_map.rs:13-21`). +- Context grouping can itself emit duplicate YAML; a second format pass then deletes a valid translation (`canonical_yaml.rs:819-874`). +- Overlay field/media alternatives are independent options and malformed combinations are accepted then discarded (`canonical_yaml.rs:2176-2225,2249-2275`; `crates/brain-brew-core/src/compose.rs:1178-1205`). + +**Reproduced evidence:** temporary `brainbrew fmt` probes retained only the second duplicate overlay/media key; a contextual `denmark` collision emitted duplicate keys and lost one value on the second pass; partial media and orphan/conflicting structured-message payloads were dropped. Existing duplicate coverage tests only a repeated struct field (`crates/brain-brew-formats/tests/manifest_yaml.rs:724-734`). + +**Gate:** reject duplicate keys at every dynamic map level and malformed unions before any source-writing workflow is considered safe. + +### C2. Locked packages can read mutable content outside the authenticated source tree + +- Absolute `package.manifest` causes `Path::join` to discard the fetched root; base/overlay paths are similarly uncontained (`crates/brain-brew-cli/src/commands/lock.rs:48,68,107-108,474-475`; `crates/brain-brew-cli/src/io.rs:94-96,298-310,415-420`). +- Snapshot extraction/copy preserves symlinks (`lock.rs:786-845`). +- `nar_hash` is optional, so manually edited mutable locks verify (`crates/brain-brew-formats/src/lockfile.rs:32-40,176-229`; `lock.rs:94-108,430-454`). + +**Reproduced evidence:** lock verification remained green after an out-of-tree host deck changed, and downstream compose emitted the changed name. A tarball whose `brainbrew.yaml` was an absolute symlink outside the archive was accepted and retained in the cache. A hashless path lock verified both before and after mutation. + +**Gate:** require typed source-specific lock fields and canonical SRI SHA-256; reject absolute/parent/symlink escapes for manifest, base, overlay, include, and media paths. + +### C3. Workbench can overwrite concurrent work, partially commit, or make canonical source noncanonical + +- Apply requests have no expected fingerprint/generation (`crates/brain-brew-cli/src/commands/workbench.rs:1636-1661`), despite fingerprints being returned (`workbench.rs:601-618,4929-4952`). +- All temp files are prepared, then targets are renamed sequentially without rollback (`workbench.rs:3878-4017`). +- Any include in a deck sends source edits through generic `serde_yaml::Value` serialization (`workbench.rs:4112-4238`), validated only as parseable YAML (`:4528-4547`). +- Confirm is independent of preview and no preview token binds edits/fingerprints (`crates/brain-brew-workbench-ui/src/lib.rs:1629-1689`; server `workbench.rs:338-359,878-1035`). + +**Reproduced evidence:** an externally changed German translation was overwritten by a stale Workbench draft with HTTP 200 and `validation.ok=true`; editing a non-included UG field in an include-bearing deck returned success but subsequent `verify` failed because `deck.yaml` was noncanonical. The checked test explicitly accepts partial rename state (`crates/brain-brew-cli/tests/cli.rs:1153-1184`). + +**Gate:** compare-and-swap all affected file fingerprints, bind Confirm to a successful preview, canonicalize through one source-document API, and implement rollback/recovery or reject batches that cannot be transactional. + +### C4. Release publication is blocked by immutable version reuse and broken artifact gates + +- All crates still declare exact `1.0.0-alpha.1`, already published with older APIs (`Cargo.toml:11-21`). +- `release:crates` downloaded published core and failed formats with 40 missing-symbol errors; path workspace tests cannot detect this. +- Package smoke installs from a workspace path, not extracted `.crate` artifacts (`.github/workflows/package-smoke.yml:14-21`). +- Nix runs all workspace targets, including browser E2E, without building/provisioning the harness (`flake.nix:25-41`; `devenv.nix:53-82`). +- The release workflow does not depend on CI/package smoke and uses mutable action tags/installers with broad credentials (`.github/workflows/release.yml:17-18,56-72,112-145,222-325`). + +**Command evidence:** `devenv shell release:smoke` passed; `devenv shell release:crates` failed with 40 API mismatch errors; `nix build .#checks.x86_64-linux.brainbrew -L --no-link` failed all 13 E2E tests because `target/debug/brainbrew` was absent. + +**Gate:** bump all publishable crates, verify extracted packages in registry dependency order, fix Nix check separation, and require a green reusable quality/artifact workflow before publication. + +### C5. Production Ultimate Geography cannot execute its documented release flow + +- Current source fails canonical checks: `deck.yaml` and `overlays/extensions/hardcore.yaml` are not formatter-canonical. +- After temporary formatting, all 546 base media hashes and five Experimental hashes are empty (`/home/jmo/Development/external/ultimate-geography/deck.yaml:5515-7153`; `overlays/variants/experimental.yaml:664-684`). +- Main export with `--media-root` creates no `deck.json`. +- UG CI points to a moving Nix branch, and the current Nix command cannot build (`/home/jmo/Development/external/ultimate-geography/.github/workflows/integrity-check.yml:13-15,25-45`). +- The migration branch is divergent from upstream and parity baseline paths are not immutable. + +**Command evidence:** real `verify --all-targets --media-root media` failed on canonical formatting; a formatted temporary main copy then failed on 546 empty hashes; real `en-standard` export failed with no output. A canonicalized temporary companion workspace verified 26 targets and exported a 45-note/56-media target, isolating the main-source problem. + +**Gate:** pin a buildable tool revision, reconcile branch history, canonicalize actual source, hash/migrate all declared media, then run mandatory independent goldens before release. + +## High findings + +### H1. Destructive composition checks are not semantically fail-closed + +`has_expected_base` checks only `Option::is_some()` (`crates/brain-brew-core/src/compose.rs:1619-1633`). Complete card-template, field-definition, note, and note-type replacement therefore does not compare the supplied prior value (`compose.rs:296-327,406-516,838-899,961-990`). Media replacement accepts presence-only baselines (`:1223-1293`). Structured image fields use an empty raw placeholder, so add/merge/field-fill can erase images while treating the field as blank (`:1124-1205`). + +Typed/path-addressed tombstones are also absent: one `BTreeSet` can alias note and note-type IDs, while template/field/media removals are not tombstoned (`crates/brain-brew-core/src/model.rs:674-685`; `compose.rs:251-293,472-487,862-872,923-958,1258-1268`; `validate.rs:203-227`). + +### H2. Structured messages and semantic diff can return wrong or false-equal state + +Messages render all references against one snapshot and do not detect cycles (`crates/brain-brew-core/src/messages.rs:95-113,133-190,300-307`). Multi-hop references can retain stale values. + +`semantic_diff` omits deck ID/adapter IDs and structured note messages/images (`crates/brain-brew-core/src/compose.rs:45-67,1972-2052`). CrowdAnki round-trip tests rely on this incomplete oracle (`crates/brain-brew-formats/tests/crowdanki.rs:25-34,163-205`; `crates/brain-brew-cli/tests/ug_style_fixture.rs:113-124`). + +### H3. Field-definition translation can break every template silently + +Coverage exposes field-definition names as translatable and application renames them (`crates/brain-brew-core/src/translation.rs:73-82,1167-1176`) but does not rewrite `{{Field}}` references in template HTML (`translation.rs:1178-1203`). Content validation checks HTML, not Mustache field references (`crates/brain-brew-core/src/content_validation.rs:93-125`). A probe translated `Capital` to `Hauptstadt`; export succeeded with field `Hauptstadt` while templates still used `{{Capital}}`. + +Decision required: make field names structural/non-translatable, or implement atomic reference rewrite plus Anki-template validation. + +### H4. Translation completeness and UG ordering are not compositional + +- Strict coverage judges each translation overlay against the entire intermediate deck (`crates/brain-brew-core/src/translation.rs:48-154`; `crates/brain-brew-cli/src/commands/verify.rs:136-187`), making split base/extension dictionaries impractical. +- UG main Hardcore stacks apply base translation, then English extension/fills, but do not select companion content translations. The separate companion manifest demonstrates the correct order (`/home/jmo/Development/external/ultimate-geography/brainbrew.yaml:55-60,367-370`; `brainbrew-hardcore.yaml:70-79`). A fresh `cs-hardcore-standard` export left American Samoa prose in English. +- Duplicated capital-hint variables are only half translated in 13 languages (`deck.yaml:14-15`; representative `overlays/languages/cs.yaml:712-724`; templates at `templates/ultimate-geography/capital-country/{question,answer}.html:8`). +- Blank direct translations count as successful global replacement/deletion (`crates/brain-brew-core/src/translation.rs:510-519,1507-1511,1549-1558`). + +### H5. CrowdAnki import/identity handling is incomplete + +- Note IDs slug only ASCII from the first field; two Cyrillic notes become `note.unnamed` (`crates/brain-brew-formats/src/crowdanki.rs:1181-1187,1289-1307`). The diagnostic mentions a suggested-ID override path that does not exist (`:1212-1214`; CLI `crates/brain-brew-cli/src/commands/import.rs:8-35`). +- Duplicate note GUIDs are not rejected and are re-exported (`crowdanki.rs:838-882,641-649,686-691`). +- Template ordinals are sorted but not required to be unique/contiguous and are renumbered on export (`crowdanki.rs:597-613,1008-1023,1085-1100`). + +**Reproduced:** Russian import collided; duplicate GUID import exited 0; ordinal `[99,1,2,3]` was accepted and emitted as `[0,1,2,3]` in changed order. + +### H6. Media/source mutators and release checks are not transactional or ownership-aware + +- `media hash` and `images-to-refs` write one source at a time (`crates/brain-brew-cli/src/commands/media.rs:40-116`), so later failure leaves earlier changes. +- Source collection includes locked dependency overlays and can rewrite content-addressed cache entries using the root package's media root (`media.rs:285-379`; `crates/brain-brew-cli/src/io.rs:395-420`). +- Verify without media root accepts empty/malformed hashes; export without media root skips reference validation (`verify.rs:47-79`; `export.rs:86-100`). +- All-target media verification rereads and retains every asset per target (`verify.rs:51-79`; `media_assets.rs:23-45`). + +A missing later asset reproduction changed the base hash before exiting 1. Mutation commands must plan/validate fully, retain package ownership, and commit only root-workspace files transactionally. + +### H7. Package registry behavior differs by discovery route + +Dependency validation runs for package roots/locks but not explicit `--include` (`crates/brain-brew-cli/src/io.rs:202-241`; `commands/targets.rs:27-38`). It does not detect package cycles or enforce `compatible_base_versions` (`package_resolver.rs:16-61`; manifest `crates/brain-brew-formats/src/manifest.rs:159-165,670-688`). `targets --json` uses local-only expansion and fails on package-qualified overlays (`commands/targets.rs:40-59`; `io.rs:460-508`). Manifest catalog ID/kind is not checked against loaded overlay identity/kind (`io.rs:314-325,382-421`). + +Reproductions showed missing dependencies accepted via `--include` and package-qualified JSON listing failing despite successful composition. + +### H8. Workbench is incomplete beyond its write-safety blockers + +- List APIs cap at 50 by default, but the UI sends no offset and offers no pagination controls (`crates/brain-brew-workbench-ui/src/lib.rs:530-599,1013-1044,1287-1345`). Items 51+ are unreachable. +- Detail mounting overwrites deck-wide progress with selected-note DOM counts (`lib.rs:2275-2287,3743-3805`). +- Apply collects current and mounted prefixes, omitting staged edits from unmounted language/target scopes (`lib.rs:3895-3921`; `staging.rs:82-105`). +- Card/source/metadata still depend on broad compatibility pivots; comparison returns all notes/strings/cards (`server workbench.rs:191-220,771-812,2437-2635`; UI `lib.rs:1813-1961,4073-4195`). +- The unauthenticated API accepts arbitrary Host/Origin and rendered deck HTML enters `inner_html` without CSP (`workbench.rs:191-229`; UI `lib.rs:1085-1091`; embedded `index.html:1-25`). + +A foreign `Host: attacker.example` / `Origin: https://attacker.example` health request returned 200 and disclosed the manifest path. Loopback binding is useful but insufficient for a local file-writing service. + +### H9. Workbench caches have correctness and measured scale failures + +- `selected_contexts` is unbounded and each entry retains three full decks, overlays, and coverage (`crates/brain-brew-cli/src/commands/workbench.rs:464-493,1186-1284,1746-1754`). Across 93 UG selections RSS rose from 20,228 KiB to 448,672 KiB. +- The first media request composes every target (`workbench.rs:1127-1183`): measured 14.630 s cold versus 0.002 s warm. +- Freshness signatures omit scalar/media includes and locked source dependencies (`workbench.rs:517-598,4929-4952`), so cached contexts can remain indefinitely stale. + +### H10. Installation/onboarding claims are not executable + +- GitHub tag/release/installer URLs return 404 and the Homebrew tap has no formula, while docs advertise them (`README.md:42-69`; `documentation/docs/getting-started/install.md:9-72`). The available crates.io alpha lacks documented `workbench`/`media` commands. +- The quickstart omits directory creation, uses noncanonical snippets, and compose does not create output parents (`documentation/docs/getting-started/quickstart.md:23-87`; `crates/brain-brew-cli/src/commands/compose.rs:31-34,74-76`). +- The documented import omits mandatory `--accept-suggested-ids` (`documentation/docs/reference/cli.md:93-99`; `commands/import.rs:15-18`). + +## Medium findings + +1. **CLI destructive/output UX:** import overwrites existing files and ignores unknown options (`crates/brain-brew-cli/src/args.rs:295-302`; `commands/import.rs:27-36`); export reuses dirty directories (`commands/export.rs:94-101`); compose does not create parents; parse errors often omit the source filename (`io.rs:68-96`); `translations --json` emits plain-text errors because JSON dispatch excludes it (`main.rs:82-87`). +2. **Media path safety:** lexical checks are duplicated and not symlink-safe; HTML-breaking path characters can enter rendered `` (`crates/brain-brew-cli/src/media_assets.rs:29-76`; `commands/media.rs:618-627`; core `compose.rs:1782-1804`). +3. **YAML typing/diagnostics:** serde coercion turns YAML booleans/null/numbers into strings; public overlay/manifest/lock emitters can panic on constructible invalid map keys; conversion errors lack schema/source location (`canonical_yaml.rs:810,1431-1455,2371-2380,2525-2569`; `manifest.rs:281-315`; `lockfile.rs:54-85`). +4. **Core error shape:** final composition flattens validation categories to `ValidationFailed` (`crates/brain-brew-core/src/compose.rs:27-39`), forcing machine clients to parse English. +5. **Translation schema debt:** `target_additions` uses the magic reason `"target addition from upstream UG"` and is undocumented (`crates/brain-brew-formats/src/canonical_yaml.rs:20,730-744,1754-1771`). Deck and note-type display identity also use inconsistent variable strategies. +6. **Verification throughput:** one UG complex target measured 0.500 s; 74 targets measured 18.910 s/18.803 s user CPU because registry/source/compose prefixes are repeated (`verify.rs:51-70,136-185`; `io.rs:173-325`; core `compose.rs:10-40`). Memory was good at sampled 24,308 KiB. +7. **Pagination computes full collections:** note/card/source list endpoints build full rows before slicing (`workbench.rs:2119-2300`); measured warm latency was similar for limit 1 and 50. +8. **Package discovery:** recursive package-root traversal does not prune `.git`, `.jj`, `target`, outputs, or symlink cycles (`crates/brain-brew-cli/src/package_resolver.rs:7-13,64-86`). +9. **Lock network budgets:** HTTP is accepted and response/extraction sizes, redirects, timeouts, and archive expansion are unbounded (`commands/lock.rs:493-537,604-686`). +10. **Dependency/release hygiene:** no Cargo/npm advisory/license gate; `npm audit --omit=dev` reported 1 high, 24 moderate, 1 low; crate archives omit LICENSE/README; release has no SBOM/provenance/signatures (`Cargo.toml:11-17`; publishable crate manifests `:1-11`). +11. **Docs/schema/recovery:** YAML reference omits variables, sparse overlay shapes, manifest fields, and stable-ID grammar; stale-resolution and lock-update recovery are underdocumented; Workbench docs describe legacy pivots (`documentation/docs/reference/yaml.md:7-47,224-244`; `reference/workbench.md:50-122`). +12. **UG source duplication/packaging:** companion identity preservation duplicates shells/catalog/translation data; docs/CI recopy all 607 media into every output and have no automated archive naming/validation; release version text is duplicated across six descriptions. + +## Low findings + +1. `diff` has no optional change-sensitive exit code (`crates/brain-brew-cli/src/commands/diff.rs:20-35`). +2. Help/version bypass strict trailing-argument validation (`crates/brain-brew-cli/src/main.rs:38-47,67-70`). +3. Include-format documentation says scalar content materializes, while implementation/tests preserve directives (`documentation/docs/reference/yaml.md:220-222`; `crates/brain-brew-formats/src/source_includes.rs:46-80`). Invalid configured include roots are silently skipped during canonicalization (`source_includes.rs:340-359`). +4. `format_source_at` carries an unused path argument (`crates/brain-brew-cli/src/io.rs:45-47`). +5. Translation contextual/stale/ignore scans may become quadratic at very large dictionary size (`crates/brain-brew-core/src/translation.rs:240-309,1804-1827`); no current UG regression was demonstrated. +6. Native UI testing covers only `WorkspaceSummary`; staging-key/storage failure behavior lacks deterministic unit tests (`crates/brain-brew-workbench-ui/tests/workspace_summary.rs:1-17`; `src/staging.rs:1-163`). +7. Accessibility semantics, live/busy status, labels, active-row state, and keyboard-only coverage are incomplete (`crates/brain-brew-workbench-ui/src/lib.rs:366-369,667-688,1553-1560,1685-1693`). + +## Undone product workflows + +These are product journeys that are absent, incomplete, or advertised beyond current behavior—not merely implementation bugs. + +1. **Reviewable CrowdAnki import:** no inspectable suggested-ID plan, selective override file, duplicate-GUID remediation, or media-byte handoff. Current flow is blanket acceptance into a new full deck. +2. **Anki-to-existing-source reconciliation:** no merge-back into an existing canonical deck/overlay stack. UG previously supported Anki-to-source recipes; import is not a replacement. +3. **Workspace initialization/scaffolding:** no `init` command or tested manual scaffold. The published quickstart is not executable. +4. **Safe source-wide mutation transaction:** media migrations, import, format, and Workbench writes do not share dry-run/plan/atomic commit/recovery semantics. +5. **Complete Workbench editing:** no reachable pagination beyond 50, no complete detail endpoints, no workspace-wide draft manager, no mandatory preview/CAS, no discard-all UX, no graceful shutdown/restart recovery. +6. **Strict compositional translation release gate:** split extension dictionaries cannot jointly satisfy policy, and structural/hidden source units dominate strict fallback counts. +7. **Federated media ownership:** no per-package media-root provenance; mutators can touch locked dependency cache sources. +8. **Independent consumer parity:** goldens are optional/absent; representative historical evidence does not compare complete template/schema/config/media state. +9. **Automated release packaging:** no reliable archive naming/layout validation or declared-media-only release transaction for UG. +10. **UG domain maintenance:** deleted flag similarity analysis has no replacement; its deprecation is not explicitly accepted. +11. **Installable documented release:** release/tag/installer/Homebrew claims do not point to the same build/schema described by current docs. +12. **Supported reusable Rust API:** crates are advertised reusable but lack consumer examples/stability commitments. + +## Architecture deepening opportunities + +### A1. One source-document mutation boundary + +Create pure, include-preserving `CanonicalSourceDocument` / `OverlaySourceDocument` operations in `brain-brew-formats`; keep authorization, bytes, transactions, and filesystem writes in CLI. Migrate Workbench, media hash/image migration, and translation insertion away from direct `serde_yaml`/line surgery (`commands/media.rs:48-65,101-115`; `commands/workbench.rs:4112-4509`; `commands/translations.rs:2682-2820`). This restores the intended crate boundary and gives duplicate/union/canonical checks one implementation. + +### A2. Honest workspace transaction service + +Replace `write_files_atomically` with a journaled backup/rollback/recovery module, or explicitly constrain operations to cases where all-or-nothing can be guaranteed. Apply the same plan/validate/commit discipline to Workbench, media mutations, import, fmt, lock update, compose, and clean-directory export. + +### A3. Centralize translation mutation policy in core + +Add domain commands such as `set_direct`, `set_contextual`, `set_no_change`, `record_source_change`, and `resolve_stale`. Today public maps leak cross-map invariants and Workbench/translation CLI independently implement precedence and cleanup (`crates/brain-brew-core/src/model.rs:700-747`; Workbench and translation ranges above). + +### A4. Typed, versioned Workbench contract + +Introduce shared request/response DTOs and enums for the independently compiled server/UI crates. Current ad hoc `serde_json::Value` indexing silently defaults on contract drift (`crates/brain-brew-cli/src/commands/workbench.rs:233-360,601-779`; UI `lib.rs:947-1385,3872-4229`). Add an explicit API version, JSON error envelope, API 404 behavior, request limits, and per-process capability. + +### A5. Complete ADR-015 list/detail architecture, then delete pivots + +Implement `card-detail`, `source-string-detail`, and `metadata-detail`; make comparison selected-item scoped; migrate UI/E2E; remove compatibility pivots after a bounded window. This bounds payload/DOM work and makes pagination meaningful. + +### A6. Deepen `DeckPath` and safe path authorization + +Expose typed parent/entity/field accessors and descendant matching instead of CLI string parsing (`crates/brain-brew-core/src/model.rs:75-620`; translations `:3015-3061`; Workbench `:4396-4430`). Separately create one CLI-owned `SafeRelativePath` plus canonical-root containment for package/media/include writes. + +### A7. Consolidate manifest planning + +Use one registry-aware planner for compose, verify, explain, targets JSON, translations, and Workbench. Remove or clearly constrain local-only expansion (`formats/src/manifest.rs:22-151`) after registry callers migrate. Retain source provenance and package ownership in plans. + +### A8. Split oversized modules along behavior boundaries + +Without proliferating public crates, split Workbench server (`routes/contracts/cache/apply/source_io/media/new_language`), UI (`api/state/views/staging/preview`), translations (`report/resolve/source_editor`), and core compose (`compose/render/semantic_diff`). Current sizes are approximately 4,985, 4,243, 3,346, and 2,169 lines respectively. + +### A9. Make performance caches bounded and dependency-complete + +Use bounded LRU/single-active selection with `Arc` sharing; include the full transitive include/package dependency set in fingerprints; cache compact list indexes; precompute declared media without all-target composition; load registry/source once and memoize composition prefixes. + +### A10. Repair architecture records + +ADR-0011/0014 still name Iced/WASM while implementation is Leptos; ADR-0015 detail migration remains incomplete. Supersede/amend ADRs, update the five-crate workspace map, and distinguish stable versus internal Workbench APIs (`documentation/docs/reference/decisions/0011-*.md`; `0014-*.md`; `0015-*.md`; `documentation/docs/reference/project-scope.md:32-40`). + +## Ultimate Geography consumer blockers + +### Immediate release blockers + +1. **Tool:** contributors pin `1.0.0-alpha.1`, CI follows a moving Nix branch, and neither maps reliably to current docs/source behavior (`CONTRIBUTING.md:24-45`; `.github/workflows/integrity-check.yml:13-15`). +2. **Canonical source:** actual `deck.yaml` and Hardcore overlay fail current formatting; a temporary format changed 20 YAML files, dominated by quoted UG tags. +3. **Media:** 546 base and five Experimental declarations have empty hashes; main verify/export with media cannot pass. +4. **Branch integration:** migration and upstream are divergent; parity baseline references are moving/machine-specific. +5. **Oracle:** no manifest target configures `exports.crowdanki.golden`; optional fixture parity silently returns success when absent (`crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:536-548,1798-1855`). + +### Consumer correctness blockers + +6. Main localized Hardcore targets omit companion content translation overlays and can ship English extension content. +7. Capital-hint duplicate variables produce mixed-language card faces in 13 languages. +8. Strict translation policy fails every translated target, including 1,226 hidden German fallbacks; it cannot certify release readiness. +9. Production manifests lack `languages`/`translation_profile`, so the documented Workbench starts but `language=de` returns HTTP 400 (`crates/brain-brew-cli/src/commands/workbench.rs:1294-1319`). +10. Historical parity checks only 12/100 targets and its model signature is `(name, template count)`; templates, CSS, field schema/order, config, and media content are not complete oracles (`scripts/collect-pr736-equivalence-evidence.py:300-355,450-476`). + +### Consumer workflow/product debt + +11. The fixture must stop being a feature delta. Sync should reproduce actual UG plus an explicit, reviewed migration step—not silently append catalog/profile state (`scripts/sync-ug-fixture.sh:49-66`). +12. Companion identity safety currently requires duplicated shells/catalog/translation files; this needs either accepted duplication with consistency checks or a deeper identity-sharing model. +13. Release docs/CI copy every media file after Brain Brew already copies declarations, masking declaration gaps and multiplying output size. +14. Version text is duplicated in six localized description fragments; release instructions point to the wrong source. +15. Anki-to-source, flag analysis, and ZIP packaging need explicit restore/deprecate decisions before “fully migrated” can be claimed. + +## Test-confidence map + +| Surface | What green tests genuinely establish | False-confidence gap / required addition | +|---|---|---| +| Core | 79 core tests; paths/invariants; broad intent/conflict and translation examples | Wrong entity expected bases; existing structured-value overwrite; typed tombstones; message graphs/cycles; complete semantic-diff mutation table | +| YAML/formats | Deterministic positive round trips, hostile scalar corpus, includes, unknown fields | Duplicate dynamic keys; malformed union matrix; strict scalar types; direct-emitter panic resistance; generated/fuzz laws | +| CrowdAnki | 42 focused tests; deterministic JSON; many unsupported fields fail closed | Unicode/repeated IDs; duplicate/effective GUID collisions; ordinal gaps/duplicates; complete normalized canonical comparison | +| Manifest/lock/media | Local cycles, path drift, cache rehash, include containment, reference scanning | Package path/symlink escape; hashless locks; discovery parity/cycles; per-package media ownership; network/resource limits | +| CLI | Real binary exercised for every principal command; JSON contracts for selected commands | Destructive overwrite/transaction failure matrix; dirty output; filename-rich parse errors; all JSON modes; cross-platform/interruption behavior | +| Workbench API | 32 named server/API tests; pagination bounds; real writes; two-request serialization | CAS stale files; canonical include write; transaction rollback/restart; preview binding; Host/Origin/capability; complete detail APIs | +| Workbench browser | 13 Chromium cases pass; broad happy paths; real UG smoke; localStorage persistence; lazy views | Item 51+ reachability; progress correctness; stale conflicts; unmounted scopes; failure UX; embedded release bundle; accessibility | +| UG fixture | 74 + 26 composition/validation; canonical/idempotent migrated source; propagation | Production checkout drift; media bytes/hashes; complete old-tool/release goldens; experimental/Hardcore parity | +| Release | Local path `release:smoke`; embedded asset freshness | Extracted `.crate` dependency chain fails; Nix package fails; no pre-tag multi-platform build/quality dependency; no SBOM/provenance | +| Docs | Docusaurus build and local links pass | Command snippets not executed; install channels dead; quickstart/import examples fail; no schema completeness check | + +### Executed aggregate evidence + +- `cargo test --workspace --exclude brain-brew-workbench-e2e --all-targets`: **404 passed, 0 failed**; UG fixture dominated runtime (~162 s of ~189 s in one audit run). +- `devenv shell e2e`: **13 passed, 0 failed** (reported runs of 16.57–22.02 s). +- `devenv shell workbench-ui-embed-check`: passed; embedded assets byte-match release build. +- No ignored tests, snapshots, property framework, fuzz target, or mutation-testing gate was found. +- The optional UG release-oracle test reports success by early return when the oracle is absent; it is not an actual skip/failure. + +## Recommended sequenced roadmap + +### Now — restore integrity and establish a releasable baseline + +1. **Freeze publication and release claims.** Bump all publishable crates from the immutable alpha; update exact internal dependencies, changelog/docs/dist tag; verify extracted packages in registry order. +2. **Make source decoding fail closed.** Reject duplicate dynamic keys and malformed overlay unions; add byte-unchanged CLI failure tests and path/location diagnostics. +3. **Close package trust boundaries.** Require valid hashes/source-specific lock fields; reject absolute/parent/symlink escapes; add HTTPS/time/size/extraction budgets. +4. **Fix core destructive invariants.** Representation-aware field values, real expected-base comparisons/fingerprints, typed tombstones, unknown structured-media validation, message DAG/cycle errors, complete semantic diff. +5. **Disable or harden unsafe Workbench writes.** Until CAS + canonical source mutation + preview binding + rollback/recovery land, clearly mark Apply experimental or read-only for release builds. +6. **Repair release gates.** Separate Nix package tests from prepared E2E; make release depend on CI/package/artifact smoke; pin actions/installers; minimize tokens; add advisory/license gates. +7. **Bring actual UG to a pinned baseline.** Reconcile branch, canonicalize source, migrate/hash all media, add language/profile catalog if Workbench remains promised, and remove fixture-only divergence. +8. **Commit mandatory representative CrowdAnki goldens.** Include source language, non-Latin, RTL/CJK, Extended, Experimental, standalone and companion Hardcore; use narrow explicit allowlists. + +**Now exit criteria:** no silent parse/format loss; no out-of-tree locked reads; all destructive core regressions covered; Workbench cannot stale/partial-write; extracted crates and Nix package build; actual UG verifies/exports with media under the pinned tool; independent goldens execute in CI. + +### Next — complete maintainer and consumer workflows + +1. Build a reviewable Unicode-safe import plan/override flow; reject GUID/ordinal identity defects; add media-byte handoff and safe output semantics. +2. Define compositional translation ownership/strictness; fix UG Hardcore overlay order and shared hint variable; decide field-name translation policy and blank deletion intent. +3. Complete Workbench pagination/detail/comparison, workspace-wide drafts, discard/recovery, graceful shutdown, capability/Host/Origin/CSP policy, and embedded-bundle E2E. +4. Introduce shared source-document operations and the workspace transaction service; migrate media/import/fmt/Workbench mutators. +5. Make manifest planning registry-consistent and provenance-aware; protect locked dependency sources and support per-package media roots. +6. Restore or explicitly deprecate UG Anki-to-source, flag-analysis, and archive-packaging workflows; single-source release version metadata. +7. Execute quickstart/import/install documentation in CI and publish a complete schema/migration/recovery reference. + +### Later — deepen and scale + +1. Split monoliths behind behavior-oriented private modules and typed Workbench contracts. +2. Add bounded shared Workbench caches, transitive fingerprints, compact list indexes, and cheap media catalogs. +3. Memoize target composition prefixes and stream/deduplicate media hashing before considering bounded parallelism. +4. Add property tests, fuzz targets, panic-resistance corpora, mutation testing, and scheduled large-media/package-root benchmarks. +5. Add SBOM, provenance, signatures, artifact attestations, pre-tag macOS/Windows/Linux target smoke, and explicit ARM Linux policy. +6. Publish supported Rust API examples/stability commitments or reclassify crates as implementation packages. + +## Open decisions + +1. **Field names:** structural and non-translatable, or translatable with complete Anki reference rewrite/validation? +2. **Expected bases:** canonical entity summaries, stable fingerprints, sparse property-only replacement, or removal of complete replacement APIs? +3. **Tombstones:** typed entity/path variants and compatibility migration format? +4. **Strict translation:** combined target-stack completeness, source-unit ownership by introducing overlay, or another compositional policy? +5. **Blank translation:** reject, warn as a distinct reviewed category, or require path-scoped target adaptation/deletion intent? +6. **Workbench threat model:** are untrusted workspaces/browser origins in scope? Regardless, should every process require a capability token and CSP/sanitization? +7. **Workbench transaction boundary:** are external include roots/cross-filesystem writes allowed in one Apply, or rejected when atomic recovery cannot be guaranteed? +8. **Workbench maturity/API:** stable versioned product surface now, or explicitly internal/experimental until detail migration and safety work finish? +9. **Media verification:** should declared media make `--media-root` mandatory for release verify/export, and can manifests configure it? +10. **Package compatibility:** exact versions only, real semver ranges, or remove inert `compatible_base_versions` until implemented? +11. **Symlinks:** reject all fetched-package symlinks, or permit only links canonically contained within the package root? +12. **UG fixture:** exact production snapshot, production plus explicit generated migration patch, or intentionally future-facing fixture with a separately mandatory live-consumer gate? +13. **UG lost workflows:** restore Anki reconciliation/flag analysis/ZIP packaging, or formally deprecate each with replacement guidance? +14. **Release channels:** crates.io only for the next preview, or synchronize crates/tag/GitHub installers/Homebrew/Nix before advertising any channel? +15. **Public Rust crates:** supported semver API surface with docs, or internal crates published only to support the CLI? + +## Speculative or unmeasured items + +The following are plausible from direct control flow but were not reproduced at production scale; they should not be presented as confirmed incidents: + +- All-target media hashing could reach 40,404 reads for 74 targets × 546 common assets and high peak asset memory; no real UG media-byte benchmark was available. +- Recursive package discovery can traverse huge irrelevant trees or symlink loops; the magnitude was not stress-tested. +- Contextual translation lookup can approach quadratic behavior with very large path-scoped dictionaries; current UG did not demonstrate a regression. +- CrowdAnki export/golden comparison may hold several complete deck/JSON copies; no UG-size memory failure was measured. +- Cross-platform rename/replacement and Windows path behavior remain untested. +- DNS rebinding is a verified acceptance surface (foreign Host/Origin returns 200), but exploit priority depends on the stated threat model and browser conditions. +- Cold remote Git/tarball lock behavior and external service failure modes were inspected, not exercised broadly against live services. + +## Acceptance report + +```acceptance-report +{ + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Synthesized all 15 requested audit artifacts into audit/16-synthesis.md only, with reconciled evidence, required sections, sequenced roadmap, and no project or external-consumer source modification." + }, + { + "id": "criterion-2", + "status": "satisfied", + "evidence": "The report preserves exact file:line and command evidence, distinguishes reproduced from speculative issues, maps all end-to-end flows and test confidence, and records residual risks for independent review." + } + ], + "changedFiles": [ + "audit/16-synthesis.md" + ], + "testsAddedOrUpdated": [], + "commandsRun": [ + { + "command": "read audit/01-flow-map.md through audit/15-release-security.md", + "result": "passed", + "summary": "Read all 15 artifacts in full (3,142 lines total) and reconciled overlapping findings and disagreements." + }, + { + "command": "read fixtures/ultimate-geography/media.yaml and crates/brain-brew-formats/tests/ultimate_geography_fixture.rs:250-339", + "result": "passed", + "summary": "Confirmed the fixture media map is hoisted/canonical but has empty hashes; tests assert entry count/equivalence rather than hash completeness." + }, + { + "command": "jj status && jj diff --summary", + "result": "passed", + "summary": "Confirmed only requested audit artifacts are working-copy additions and no product/source files are changed." + }, + { + "command": "python3 synthesis structure and acceptance-report validation", + "result": "passed", + "summary": "Validated required section headings and parsed the fenced acceptance-report as JSON." + } + ], + "validationOutput": [ + "All 15 source artifacts read: 3,142 lines.", + "Required synthesis sections present, including a separately labeled speculative/unmeasured section.", + "Acceptance report JSON parsed successfully.", + "Current working-copy changes are audit artifacts only; no project/source or Ultimate Geography source file is modified." + ], + "residualRisks": [ + "This is a synthesis/review artifact; no product fixes or regression tests were added.", + "Several source audits used temporary adversarial fixtures and code-path reasoning; speculative or unmeasured items are explicitly separated.", + "The live Ultimate Geography checkout is divergent and not a clean production baseline, so its release state must be revalidated after reconciliation.", + "External release-channel availability and dependency advisory counts are time-sensitive as of 2026-07-09.", + "Review gate remains required." + ], + "noStagedFiles": true, + "notes": "Jujutsu has no staging index. The allowed output is audit/16-synthesis.md; audit/01 through audit/15 were pre-existing inputs from the parallel audit." +} +``` diff --git a/brain_brew/__init__.py b/brain_brew/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/__init__.py b/brain_brew/build_tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/__init__.py b/brain_brew/build_tasks/crowd_anki/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py b/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py deleted file mode 100644 index b9d99e2..0000000 --- a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py +++ /dev/null @@ -1,81 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper - - -@dataclass -class CrowdAnkiGenerate(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - folder: str() - headers: str() - notes: include('{NotesToCrowdAnki.task_name()}') - note_models: include('{NoteModelsToCrowdAnki.task_name()}') - media: include('{MediaGroupToCrowdAnki.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesToCrowdAnki, NoteModelsToCrowdAnki, MediaGroupToCrowdAnki} - - @dataclass - class Representation(RepresentationBase): - folder: str - notes: dict - note_models: dict - headers: dict - media: Optional[dict] = field(default_factory=lambda: dict()) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowd_anki_export=CrowdAnkiExport.create_or_get(rep.folder), - notes_transform=NotesToCrowdAnki.from_repr(rep.notes), - note_model_transform=NoteModelsToCrowdAnki.from_repr(rep.note_models), - headers_transform=HeadersToCrowdAnki.from_repr(rep.headers), - media_transform=MediaGroupToCrowdAnki.from_repr(rep.media) if rep.media else None - ) - - rep: Representation - crowd_anki_export: CrowdAnkiExport - notes_transform: NotesToCrowdAnki - note_model_transform: NoteModelsToCrowdAnki - headers_transform: HeadersToCrowdAnki - media_transform: Optional[MediaGroupToCrowdAnki] - - def execute(self): - headers = self.headers_transform.execute() - ca_wrapper = CrowdAnkiJsonWrapper(headers) - - note_models: List[dict] = self.note_model_transform.execute() - - nm_name_to_id: dict = {model.part_id: model.part.id for model in self.note_model_transform.note_models} - notes = self.notes_transform.execute(nm_name_to_id) - - media_files: Set[MediaFile] = set() - if self.media_transform: - media_files = self.media_transform.execute(self.crowd_anki_export.media_loc) - - ca_wrapper.media_files = sorted([m.filename for m in media_files]) - ca_wrapper.name = self.headers_transform.headers.name - ca_wrapper.note_models = note_models - ca_wrapper.notes = notes - - # Set to CrowdAnkiExport - self.crowd_anki_export.write_to_files(ca_wrapper.data) diff --git a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py b/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py deleted file mode 100644 index 3f36ba7..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py +++ /dev/null @@ -1,69 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES, \ - CA_CHILDREN, CA_TYPE -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.headers import Headers - -headers_skip_keys = [CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES] -headers_default_values = { - CA_TYPE: "Deck", - CA_CHILDREN: [], -} - - -@dataclass -class HeadersFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'headers_from_crowd_anki' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - headers = Headers(self.crowd_anki_to_headers(ca_wrapper.data)) - - return PartHolder.override_or_create(self.part_id, self.save_to_file, headers) - - @staticmethod - def crowd_anki_to_headers(ca_data: dict): - return {key: value for key, value in ca_data.items() - if key not in headers_skip_keys and key not in headers_default_values.keys()} diff --git a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py deleted file mode 100644 index 9a85a7e..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import headers_default_values -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersToCrowdAnki: - @dataclass - class Representation(RepresentationBase): - part_id: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support single string being passed in - - return cls( - rep=rep, - headers=PartHolder.from_file_manager(rep.part_id).part - ) - - rep: Representation - headers: Headers - - def execute(self) -> dict: - headers = self.headers_to_crowd_anki(self.headers.data_without_name) - - return headers - - @staticmethod - def headers_to_crowd_anki(headers_data: dict): - return {**headers_default_values, **headers_data} - diff --git a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py deleted file mode 100644 index 29cf421..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py +++ /dev/null @@ -1,39 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromCrowdAnki(MediaGroupFromFolder): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_crowd_anki" - - @classmethod - def from_repr(cls, data: Union[MediaGroupFromFolder.Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - cae: CrowdAnkiExport = CrowdAnkiExport.create_or_get(rep.source) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(cae.media_loc, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - ) - ) - - rep: MediaGroupFromFolder.Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py deleted file mode 100644 index 705d3a0..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Union, List, Set - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class MediaGroupToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'media_group_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)) - ) - - rep: Representation - parts: List[MediaGroup] - - def execute(self, ca_media_folder: str) -> Set[MediaFile]: - return save_media_groups_to_location(self.parts, ca_media_folder, True, False) diff --git a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py deleted file mode 100644 index 583ad5f..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelSingleFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - model_name: str - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - if self.model_name not in note_models_dict: - raise ReferenceError(f"Missing Note Model '{self.model_name}' in CrowdAnki file") - - part = NoteModel.from_crowdanki(note_models_dict[self.model_name]) - return PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py deleted file mode 100644 index 17e2950..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsAllFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_all_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: str() - ''' - - @dataclass - class Representation(RepresentationBase): - source: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source) - ) - - rep: Representation - ca_export: CrowdAnkiExport - - def execute(self) -> List[PartHolder[NoteModel]]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - parts = [] - for name, model in note_models_dict.items(): - parts.append(PartHolder.override_or_create(name, None, NoteModel.from_crowdanki(model))) - - logging.info(f"Found {len(parts)} note model{'s' if len(parts) > 1 else ''} in CrowdAnki Export: '" - + "', '".join(note_models_dict.keys()) + "'") - - return parts diff --git a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py deleted file mode 100644 index a08e613..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(include('{cls.NoteModelListItem.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {cls.NoteModelListItem} - - @dataclass - class NoteModelListItem(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki_item' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support string - - return cls( - rep=rep, - part_to_read=rep.part_id - ) - - def get_note_model(self) -> PartHolder[NoteModel]: - self.part = PartHolder.from_file_manager(self.part_to_read) - return self.part # Todo: add filters in here - - rep: Representation - part: PartHolder[NoteModel] = field(init=False) - part_to_read: str - - @dataclass - class Representation(RepresentationBase): - parts: List[Union[dict, str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, List[str]]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(parts=data) # Support list of Note Models - - note_model_items = list(map(cls.NoteModelListItem.from_repr, rep.parts)) - return cls( - rep=rep, - note_models=[nm.get_note_model() for nm in note_model_items] - ) - - rep: Representation - note_models: List[PartHolder[NoteModel]] - - def execute(self) -> List[dict]: - return [model.part.encode_as_crowdanki() for model in self.note_models] diff --git a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py deleted file mode 100644 index d38fc27..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Union, Optional, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper, CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note - - -@dataclass -class NotesFromCrowdAnki(SharedBaseNotes, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - sort_order: Optional[List[str]] - reverse_sort: Optional[bool] - save_to_file: Optional[str] - - def execute(self) -> PartHolder[Notes]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - if ca_wrapper.children: - logging.warning("Child Decks / Sub-decks are not currently supported.") - - ca_models = self.ca_export.note_models - ca_notes = ca_wrapper.notes - - nm_id_to_name: dict = {model.id: model.name for model in ca_models} - note_list = [self.ca_note_to_note(note, nm_id_to_name) for note in ca_notes] - - notes = Notes.from_list_of_notes(note_list) # TODO: pass in sort method - return PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def ca_note_to_note(note: dict, nm_id_to_name: dict) -> Note: - wrapper = CrowdAnkiNoteWrapper(note) - - return Note( - note_model=nm_id_to_name[wrapper.note_model], - tags=wrapper.tags, - guid=wrapper.guid, - fields=wrapper.fields, - flags=wrapper.flags - ) diff --git a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py deleted file mode 100644 index 3a249ce..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py +++ /dev/null @@ -1,96 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.build_tasks.overrides.notes_override import NotesOverride -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.utils import blank_str_if_none - - -@dataclass -class NotesToCrowdAnki(YamlRepr, SharedBaseNotes): - @classmethod - def task_name(cls) -> str: - return r'notes_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('{NotesOverride.task_name()}', required=False) - case_insensitive_sort: bool(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesOverride} - - @dataclass - class Representation(RepresentationBase): - part_id: str - additional_items_to_add: Optional[dict] = field(default_factory=lambda: None) - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[dict] = field(default_factory=lambda: None) - case_insensitive_sort: Optional[bool] = field(default_factory=lambda: None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.part_id).part, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - additional_items_to_add=rep.additional_items_to_add or {}, - override=NotesOverride.from_repr(rep.override) if rep.override else None, - case_insensitive_sort=rep.case_insensitive_sort or True - ) - - rep: Representation - notes: Notes - additional_items_to_add: dict - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[NotesOverride] = field(default_factory=lambda: None) - case_insensitive_sort: bool = field(default=True) - - def execute(self, nm_name_to_id: dict) -> List[dict]: - - notes = self.notes.get_sorted_notes_copy( - sort_by_keys=self.sort_order, - reverse_sort=self.reverse_sort, - case_insensitive_sort=self.case_insensitive_sort - ) - - if self.override: - notes = [self.override.override(note) for note in notes] - - note_dicts = [self.note_to_ca_note(note, nm_name_to_id, self.additional_items_to_add) for note in notes] - - return note_dicts - - @staticmethod - def note_to_ca_note(note: Note, nm_name_to_id: dict, additional_items_to_add: dict) -> dict: - wrapper = CrowdAnkiNoteWrapper({ - "__type__": "Note", - "data": "" - }) - - for key, value in additional_items_to_add.items(): - wrapper.data[key] = blank_str_if_none(value) - - wrapper.fields = note.fields - wrapper.flags = note.flags - wrapper.guid = note.guid - wrapper.note_model = nm_name_to_id[note.note_model] - wrapper.tags = note.tags - - return wrapper.data diff --git a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py b/brain_brew/build_tasks/crowd_anki/shared_base_notes.py deleted file mode 100644 index 9ad016f..0000000 --- a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union, List - - -@dataclass -class SharedBaseNotes: - @staticmethod - def _get_sort_order(sort_order: Optional[Union[str, List[str]]]): - if isinstance(sort_order, list): - return sort_order - elif isinstance(sort_order, str): - return [sort_order] - return [] - - @staticmethod - def _get_reverse_sort(reverse_sort: Optional[bool]): - return reverse_sort or False - - # sort_order: Optional[List[str]] - # reverse_sort: Optional[bool] diff --git a/brain_brew/build_tasks/csvs/__init__.py b/brain_brew/build_tasks/csvs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/csvs/csvs_generate.py b/brain_brew/build_tasks/csvs/csvs_generate.py deleted file mode 100644 index 3cdfba1..0000000 --- a/brain_brew/build_tasks/csvs/csvs_generate.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import dataclass -import logging -from typing import List, Dict, Union - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import join_tags - - -@dataclass -class CsvsGenerate(SharedBaseCsvs, TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_csvs?' - - @classmethod - def yamale_schema(cls) -> str: # TODO: Use NotesOverride here, just as in NotesToCrowdAnki - return f'''\ - notes: str() - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - notes: str - - def encode(self): - return { - "notes": self.notes, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.notes), - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - notes: PartHolder[Notes] # TODO: Accept Multiple Note Parts - - def execute(self): - self.verify_contents() - - notes: List[Note] = self.notes.part.get_sorted_notes_copy( - sort_by_keys=[], - reverse_sort=False, - case_insensitive_sort=True - ) - self.verify_notes_match_note_model_mappings(notes) - - if not self.file_mappings[0].csv_file.column_headers: - logging.warning("Empty top level csv found. Populating headers automatically.") - model_name = self.file_mappings[0].note_model - self.file_mappings[0].csv_file.set_data_from_superset({}, column_header_override=list(f.value for f in self.note_model_mappings[model_name].columns_manually_mapped)) - - for fm in self.file_mappings: - csv_data: List[dict] = [self.note_to_csv_row(note, self.note_model_mappings) for note in notes - if note.note_model in fm.get_used_note_model_names()] - rows_by_guid = {row["guid"]: row for row in csv_data} - - fm.compile_data() - fm.set_relevant_data(rows_by_guid) - fm.write_file_on_close() - - def verify_notes_match_note_model_mappings(self, notes: List[Note]): - note_models_used = {note.note_model for note in notes} - errors = [TypeError(f"Unknown note model type '{model}' in deck part '{self.notes.part_id}'. " - f"Add mapping for that model.") - for model in note_models_used if model not in self.note_model_mappings.keys()] - - if errors: - raise Exception(errors) - - @staticmethod - def note_to_csv_row(note: Note, note_model_mappings: Dict[str, NoteModelMapping]) -> dict: - nm_name = note.note_model - row = note_model_mappings[nm_name].note_models[nm_name].part.zip_field_to_data(note.fields) - row["guid"] = note.guid - row["tags"] = join_tags(note.tags) - # TODO: Flags? - - return note_model_mappings[nm_name].note_fields_map_to_csv_row(row) diff --git a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py b/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py deleted file mode 100644 index 493a3e1..0000000 --- a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.utils import single_item_to_list, generate_anki_guid - - -@dataclass -class GenerateGuidsInCsvs(TopLevelBuildTask): - execute_immediately = True - - @classmethod - def task_name(cls) -> str: - return r'generate_guids_in_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_guids_in_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - source: Union[str, List[str]] - columns: Union[str, List[str]] - delimiter: Optional[str] = field(default=None) - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and all(CsvFile.delimiter_matches_file_type(value, f) for f in single_item_to_list(self.source)): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv_files = [CsvFile.create_or_get(csv) for csv in single_item_to_list(rep.source)] - for c in csv_files: - c.set_delimiter(rep.delimiter) - c.read_file() - return cls( - rep=rep, - sources=csv_files, - columns=rep.columns - ) - - rep: Representation - sources: List[CsvFile] - columns: List[str] - - def execute(self): - logging.info("Attempting to generate Guids") - - errors = [] - - # Make sure the columns exist on all - for source in self.sources: - missing = [c for c in self.columns if c not in source.column_headers] - if any(missing): - errors.append(f"Csv '{source.file_location}' does not contain all the specified columns: {missing}") - - if errors: - raise KeyError(errors) - - for source in self.sources: - guids_generated = 0 - data = source.get_data() - for row in data: - for column_name in row.keys(): - if column_name in self.columns and not row[column_name]: - row[column_name] = generate_anki_guid() - guids_generated += 1 - if guids_generated > 0: - logging.info(f"Generated {guids_generated} guids in csv '{source.file_location}'") - source.set_data(data) - source.write_file() - - logging.info("Generate guids complete") diff --git a/brain_brew/build_tasks/csvs/notes_from_csvs.py b/brain_brew/build_tasks/csvs/notes_from_csvs.py deleted file mode 100644 index bd2beb4..0000000 --- a/brain_brew/build_tasks/csvs/notes_from_csvs.py +++ /dev/null @@ -1,92 +0,0 @@ -from dataclasses import dataclass, field -from typing import Dict, List, Union, Optional - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Note, Notes -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import split_tags - - -@dataclass -class NotesFromCsvs(SharedBaseCsvs, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'notes_from_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - part_id: str - save_to_file: Optional[str] = field(default=None) - - def encode(self): - return { - "part_id": self.part_id, - "save_to_file": self.save_to_file, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - save_to_file=rep.save_to_file, - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - part_id: str - save_to_file: Optional[str] - - def execute(self): - self.verify_contents() - - csv_data_by_guid: Dict[str, dict] = {} - for csv_map in self.file_mappings: - csv_map.compile_data() - csv_data_by_guid = {**csv_data_by_guid, **csv_map.compiled_data} - csv_map.write_file_on_close() - csv_rows: List[dict] = list(csv_data_by_guid.values()) - - notes_part: List[Note] = [self.csv_row_to_note(row, self.note_model_mappings) for row in csv_rows] - - notes = Notes.from_list_of_notes(notes_part) - PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def csv_row_to_note(row: dict, note_model_mappings: Dict[str, NoteModelMapping]) -> Note: - note_model_name = row["note_model"] # TODO: Use object - row_nm: NoteModelMapping = note_model_mappings[note_model_name] - - filtered_fields = row_nm.csv_row_map_to_note_fields(row) - - guid = filtered_fields.pop("guid") - tags = split_tags(filtered_fields.pop("tags")) - flags = filtered_fields.pop("flags") if "flags" in filtered_fields else 0 - - fields = row_nm.field_values_in_note_model_order(note_model_name, filtered_fields) - - return Note(guid=guid, tags=tags, note_model=note_model_name, fields=fields, flags=flags) diff --git a/brain_brew/build_tasks/csvs/shared_base_csvs.py b/brain_brew/build_tasks/csvs/shared_base_csvs.py deleted file mode 100644 index 916b61d..0000000 --- a/brain_brew/build_tasks/csvs/shared_base_csvs.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import List, Dict - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping - - -@dataclass -class SharedBaseCsvs: - @dataclass(init=False) - class Representation(RepresentationBase): - file_mappings: List[FileMapping.Representation] - note_model_mappings: List[NoteModelMapping.Representation] - - def __init__(self, file_mappings, note_model_mappings): - self.file_mappings = list(map(FileMapping.Representation.from_dict, file_mappings)) - self.note_model_mappings = list(map(NoteModelMapping.Representation.from_dict, note_model_mappings)) - - def get_file_mappings(self) -> List[FileMapping]: - return list(map(FileMapping.from_repr, self.file_mappings)) - - file_mappings: List[FileMapping] - note_model_mappings: Dict[str, NoteModelMapping] - - @classmethod - def map_nmm(cls, nmm_to_map): - nmm = NoteModelMapping.from_repr(nmm_to_map) - return nmm.get_note_model_mapping_dict() - - def verify_contents(self): - errors = [] - - for nm in self.note_model_mappings.values(): - try: - nm.verify_contents() - except KeyError as e: - errors.append(e) - - # Check all referenced note models have a mapping - for csv_map in self.file_mappings: - for nm in csv_map.get_used_note_model_names(): - if nm not in self.note_model_mappings.keys(): - errors.append(f"Missing Note Model Map for {nm}") - - # Check each of the Csvs (or their derivatives) contain all the necessary columns for their stated note model - for cfm in self.file_mappings: - note_model_names = cfm.get_used_note_model_names() - available_columns = cfm.get_available_columns() - - referenced_note_models_maps = [value for key, value in self.note_model_mappings.items() - if key in note_model_names] - for nm_map in referenced_note_models_maps: - for holder in nm_map.note_models.values(): - if holder.part.name in note_model_names: - missing_columns = [col for col in holder.part.field_names_lowercase if - col not in nm_map.csv_headers_map_to_note_fields(available_columns)] - if missing_columns: - logging.warning(f"Csvs are missing columns from {holder.part_id} {missing_columns}") - - if errors: - raise Exception(errors) diff --git a/brain_brew/build_tasks/deck_parts/__init__.py b/brain_brew/build_tasks/deck_parts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/deck_parts/from_yaml_part.py b/brain_brew/build_tasks/deck_parts/from_yaml_part.py deleted file mode 100644 index a87b54d..0000000 --- a/brain_brew/build_tasks/deck_parts/from_yaml_part.py +++ /dev/null @@ -1,61 +0,0 @@ -from abc import ABCMeta -from dataclasses import dataclass -from typing import Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.representation.yaml.notes import Notes -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class FromYamlPartBase(BuildPartTask, metaclass=ABCMeta): - part_type = None - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=cls.part_type.from_yaml_file(rep.file)) - ) - - def execute(self): - pass - - rep: Representation - part: YamlObject - - -@dataclass -class NotesFromYamlPart(FromYamlPartBase): - @classmethod - def task_name(cls) -> str: - return r'notes_from_yaml_part' - - part_type = Notes - - -@dataclass -class MediaGroupFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'media_group_from_yaml_part' - - part_type = MediaGroup diff --git a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py deleted file mode 100644 index b58c16f..0000000 --- a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py +++ /dev/null @@ -1,67 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.build_tasks.overrides.headers_override import HeadersOverride -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersFromYamlPart(BuildPartTask): - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - override: include('{HeadersOverride.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {HeadersOverride} - - @classmethod - def task_name(cls) -> str: - return r'headers_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_yaml_part' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - override: Optional[dict] = field(default_factory=lambda: None) - - def encode(self): - d = { - "part_id": self.part_id, - "file": self.file - } - if self.override: - d.setdefault("override", self.override) - return d - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - headers=PartHolder.override_or_create( - part_id=rep.part_id, - save_to_file=None, - part=Headers.from_yaml_file(rep.file) - ).part, - override=HeadersOverride.from_repr(rep.override) if rep.override else None - ) - - rep: Representation - headers: Headers - override: Optional[HeadersOverride] - - def execute(self): - if self.override: - self.headers = self.override.override(self.headers) diff --git a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py b/brain_brew/build_tasks/deck_parts/media_group_from_folder.py deleted file mode 100644 index 4df8644..0000000 --- a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py +++ /dev/null @@ -1,58 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromFolder(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - filter_blacklist_from_parts: List[str] = field(default_factory=list) - filter_whitelist_from_parts: List[str] = field(default_factory=list) - recursive: Optional[bool] = field(default=True) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(rep.source, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - # match criteria - ) - ) - - rep: Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py b/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py deleted file mode 100644 index cff5da2..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py +++ /dev/null @@ -1,78 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template - - -@dataclass -class NoteModelFromHTMLParts(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_html_parts' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - model_id: str() - css_file: str() - fields: list(include('{Field.task_name()}')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field} - - @dataclass - class Representation(RepresentationBase): - part_id: str - model_id: str - css_file: str - fields: List[dict] - templates: List[dict] - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - model_id=rep.model_id, - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=True), - fields=list(map(Field.from_dict, rep.fields)), - templates=list(holder.part for holder in map(PartHolder.from_file_manager, rep.templates)), - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - model_id: str - css: str - fields: List[Field] - templates: List[Template] - model_name: str - save_to_file: Optional[str] - - def execute(self): - part = NoteModel( - name=self.model_name, - id=self.model_id, - css=self.css, - fields=self.fields, - templates=self.templates, - required_fields_per_template=[] - ) - - PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py deleted file mode 100644 index bdf5679..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.from_yaml_part import FromYamlPartBase -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'note_models?_from_yaml_part' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - # TODO: Overrides - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=NoteModel.from_yaml_file(rep.file)) - ) - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py b/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py deleted file mode 100644 index 4b1d707..0000000 --- a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class SaveMediaGroupsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_media_groups_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_media_groups?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_folder: Optional[bool] = field(default=None) - recursive: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_folder=rep.clear_folder or False, - recursive=rep.recursive or False - ) - - rep: Representation - parts: List[MediaGroup] - folder: str - clear_folder: bool - recursive: bool - - def execute(self): - save_media_groups_to_location(self.parts, self.folder, self.clear_folder, self.recursive) diff --git a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py b/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py deleted file mode 100644 index a05ab04..0000000 --- a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional, Dict - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.transformers.save_note_model_to_location import save_note_model_to_location - - -@dataclass -class SaveNoteModelsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_note_models_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_note_models?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_existing: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_existing=rep.clear_existing or False, - ) - - rep: Representation - parts: List[NoteModel] - folder: str - clear_existing: bool - - def execute(self) -> Dict[str, str]: - model_yaml_files: Dict[str, str] = {} - for model in self.parts: - model_yaml_files.setdefault( - model.name, - save_note_model_to_location(model, self.folder, self.clear_existing) - ) - return model_yaml_files diff --git a/brain_brew/build_tasks/overrides/__init__.py b/brain_brew/build_tasks/overrides/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/overrides/headers_override.py b/brain_brew/build_tasks/overrides/headers_override.py deleted file mode 100644 index 3f0b436..0000000 --- a/brain_brew/build_tasks/overrides/headers_override.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"headers_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - crowdanki_uuid: Optional[str] = field(default=None) - deck_description_html_file: Optional[str] = field(default=None) - name: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowdanki_uuid=rep.crowdanki_uuid, - deck_desc_html_file=HTMLFile.create_or_get(rep.deck_description_html_file), - name=rep.name - ) - - rep: Representation - crowdanki_uuid: Optional[str] - deck_desc_html_file: Optional[HTMLFile] - name: Optional[str] - - def override(self, header: Headers): - if self.deck_desc_html_file: - header.description = self.deck_desc_html_file.get_data(deep_copy=True) - - if self.crowdanki_uuid: - header.crowdanki_uuid = self.crowdanki_uuid - - if self.name: - header.name = self.name - - return header diff --git a/brain_brew/build_tasks/overrides/notes_override.py b/brain_brew/build_tasks/overrides/notes_override.py deleted file mode 100644 index 8ce4604..0000000 --- a/brain_brew/build_tasks/overrides/notes_override.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.notes import Note - - -@dataclass -class NotesOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"notes_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_model: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_model: Optional[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - note_model=rep.note_model - ) - - rep: Representation - note_model: Optional[str] - - def override(self, note: Note): - if self.note_model: - note.note_model = self.note_model - - return note diff --git a/brain_brew/commands/__init__.py b/brain_brew/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/argument_reader.py b/brain_brew/commands/argument_reader.py deleted file mode 100644 index 88d1371..0000000 --- a/brain_brew/commands/argument_reader.py +++ /dev/null @@ -1,117 +0,0 @@ -from enum import Enum - -import sys -from argparse import ArgumentParser - -from brain_brew.front_matter import latest_version_number -from brain_brew.commands.init_repo.init_repo import InitRepo -from brain_brew.commands.run_recipe.run_recipe import RunRecipe -from brain_brew.interfaces.command import Command - - -class Commands(Enum): - RUN_RECIPE = "run" - INIT_REPO = "init" - - -class BBArgumentReader(ArgumentParser): - def __init__(self, test_mode=False): - super().__init__( - prog="brainbrew", - description='Manage Flashcards by transforming them to various types.' - ) - - self._set_parser_arguments() - - if not test_mode and len(sys.argv) == 1: - self.print_help(sys.stderr) - sys.exit(1) - - def _set_parser_arguments(self): - - subparsers = self.add_subparsers(parser_class=ArgumentParser, help='Commands that can be run', dest="command") - - parser_run = subparsers.add_parser( - Commands.RUN_RECIPE.value, - help="Run a recipe file. This will convert some data to another format, based on the instructions in the recipe file." - ) - parser_run.add_argument( - "recipe", - metavar="recipe", - type=str, - help="Yaml file to use as the recipe" - ) - parser_run.add_argument( - "--verify", "-v", - action="store_true", - dest="verify_only", - default=False, - help="Only verify the recipe contents, without running it." - ) - - parser_init = subparsers.add_parser( - Commands.INIT_REPO.value, - help="Initialise a Brain Brew repository, using a CrowdAnki export as the base data." - ) - parser_init.add_argument( - "crowdanki_folder", - metavar="crowdanki_folder", - type=str, - help="The folder that stores the CrowdAnki files to build this repo from" - ) - parser_init.add_argument( - '--delimiter', - dest='delimiter', - action='store', - help="Set the delimiter for Csv files to specific character", - type=str - ) - parser_init.add_argument( - "--delimitertab", "--tab", - action="store_true", - dest="delimiter_tab", - default=False, - help="Use tabs as the delimiter for Csv files" - ) - - def get_parsed(self, override_args=None) -> Command: - parsed_args = self.parse_args(args=override_args) - - if parsed_args.command == Commands.RUN_RECIPE.value: - # Required - recipe = self.error_if_blank(parsed_args.recipe) - - # Optional - verify_only = parsed_args.verify_only - - return RunRecipe( - recipe_file_name=recipe, - verify_only=verify_only - ) - - if parsed_args.command == Commands.INIT_REPO.value: - # Required - crowdanki_folder = parsed_args.crowdanki_folder - delimiter = parsed_args.delimiter - delimiter_tab = parsed_args.delimiter_tab - - return InitRepo( - crowdanki_folder=crowdanki_folder, - delimiter="\t" if delimiter_tab else delimiter - ) - - raise KeyError("Unknown Command") - - def error_if_blank(self, arg): - if arg == "" or arg is None: - self.error("Required argument missing") - return arg - - def error(self, message): - sys.stderr.write('error: %s\n' % message) - self.print_help() - sys.exit(2) - - def print_help(self, message=None): - print(f"Brain Brew v{latest_version_number()}") - super().print_help(message) diff --git a/brain_brew/commands/init_repo/__init__.py b/brain_brew/commands/init_repo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/init_repo/init_repo.py b/brain_brew/commands/init_repo/init_repo.py deleted file mode 100644 index 3963376..0000000 --- a/brain_brew/commands/init_repo/init_repo.py +++ /dev/null @@ -1,208 +0,0 @@ -import os -from dataclasses import dataclass -from typing import List - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask, BuildPartTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.interfaces.command import Command -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import create_path_if_not_exists, filename_from_full_path, folder_name_from_full_path - -RECIPE_MEDIA = "deck_media" -RECIPE_HEADERS = "deck_headers" -RECIPE_NOTES = "deck_notes" - -LOC_RECIPES = "recipes/" -LOC_BUILD = "build/" -LOC_DATA = "src/data/" -LOC_HEADERS = "src/headers/" -LOC_NOTE_MODELS = "src/note_models/" -LOC_MEDIA = "src/media/" - - -@dataclass -class InitRepo(Command): - crowdanki_folder: str - delimiter: str - - def execute(self): - self.setup_repo_structure() - - # Create the Deck Parts used - headers_ca, note_models_all_ca, notes_ca, media_group_ca = self.parts_from_crowdanki(self.crowdanki_folder) - - headers = headers_ca.execute().part - headers_name = LOC_HEADERS + "header1.yaml" - headers.dump_to_yaml(headers_name) - # TODO: desc file - - note_models = [m.part for m in note_models_all_ca.execute()] - - notes = notes_ca.execute().part - used_note_models_in_notes = notes.get_all_known_note_model_names() - - media_group_ca.execute() - - note_model_mappings = [NoteModelMapping.Representation([model.name for model in note_models])] - file_mappings: List[FileMapping.Representation] = [] - - csv_files = [] - - for model in note_models: - if model.name in used_note_models_in_notes: - csv_file_path = os.path.join(LOC_DATA, CsvFile.to_filename_csv(model.name, self.delimiter)) - column_headers = ["guid"] + model.field_names_lowercase + ["tags"] - CsvFile.create_file_with_headers(csv_file_path, column_headers, delimiter=self.delimiter) - - file_mappings.append(FileMapping.Representation( - file=csv_file_path, - note_model=model.name, - delimiter=self.delimiter - )) - - csv_files.append(csv_file_path) - - deck_path = os.path.join(LOC_BUILD, folder_name_from_full_path(self.crowdanki_folder)) - - # Generate the Source files that will be kept in the repo - save_note_models_to_folder = SaveNoteModelsToFolder.from_repr(SaveNoteModelsToFolder.Representation( - [m.name for m in note_models], LOC_NOTE_MODELS, True - )) - model_name_to_file_dict = save_note_models_to_folder.execute() - - save_media_to_folder = SaveMediaGroupsToFolder.from_repr(SaveMediaGroupsToFolder.Representation( - parts=[RECIPE_MEDIA], folder=LOC_MEDIA, recursive=True, clear_folder=True - )) - save_media_to_folder.execute() - - generate_csvs = CsvsGenerate.from_repr({ - 'notes': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - generate_csvs.execute() - - # Create Recipes - - # Anki to Source - headers_recipe, note_models_all_recipe, notes_recipe, media_group_recipe = self.parts_from_crowdanki(deck_path) - - build_part_tasks: List[BuildPartTask] = [ - headers_recipe, - notes_recipe, - note_models_all_recipe, - media_group_recipe, - ] - dp_builder = PartsBuilder(build_part_tasks) - - top_level_tasks: List[TopLevelBuildTask] = [dp_builder, save_media_to_folder, generate_csvs] - self.create_yaml_from_top_level(top_level_tasks, os.path.join(LOC_RECIPES, "anki_to_source")) - - # Source to Anki - note_models_from_yaml = [ - NoteModelsFromYamlPart.from_repr(NoteModelsFromYamlPart.Representation(name, file)) - for name, file in model_name_to_file_dict.items() - ] - - media_group_from_folder = MediaGroupFromFolder.from_repr(MediaGroupFromFolder.Representation( - part_id=RECIPE_MEDIA, source=LOC_MEDIA, recursive=True - )) - - headers_from_yaml = HeadersFromYamlPart.from_repr(HeadersFromYamlPart.Representation( - part_id=RECIPE_HEADERS, file=headers_name - )) - - notes_from_csv = NotesFromCsvs.from_repr({ - 'part_id': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - - build_part_tasks: List[BuildPartTask] = note_models_from_yaml + [ - headers_from_yaml, - notes_from_csv, - media_group_from_folder, - ] - dp_builder = PartsBuilder(build_part_tasks) - - generate_guids_in_csv = GenerateGuidsInCsvs.from_repr(GenerateGuidsInCsvs.Representation( - source=csv_files, columns=["guid"], delimiter=self.delimiter - )) - - generate_crowdanki = CrowdAnkiGenerate.from_repr(CrowdAnkiGenerate.Representation( - folder=deck_path, - notes=NotesToCrowdAnki.Representation( - part_id=RECIPE_NOTES - ).encode(), - headers=RECIPE_HEADERS, - media=MediaGroupToCrowdAnki.Representation( - parts=[RECIPE_MEDIA] - ).encode(), - note_models=NoteModelsToCrowdAnki.Representation( - parts=[NoteModelsToCrowdAnki.NoteModelListItem.Representation(name).encode() - for name, file in model_name_to_file_dict.items()] - ).encode() - )) - - top_level_tasks: List[TopLevelBuildTask] = [generate_guids_in_csv, dp_builder, generate_crowdanki] - source_to_anki_path = os.path.join(LOC_RECIPES, "source_to_anki.yaml") - self.create_yaml_from_top_level(top_level_tasks, source_to_anki_path) - - print(f"\nRepo Init complete. You should now run `brainbrew run {source_to_anki_path}`") - - @staticmethod - def create_yaml_from_top_level(top_tasks: List[TopLevelBuildTask], filepath: str): - tl_builder = TopLevelBuilder(top_tasks) - - encoded_top_level_tasks = tl_builder.encode() - # print(encoded_top_level_tasks) - - model_yaml_file_name = YamlObject.to_filename_yaml(filepath) - YamlObject.dump_to_yaml_file(model_yaml_file_name, encoded_top_level_tasks) - - @staticmethod - def parts_from_crowdanki(folder: str): - headers_ca = HeadersFromCrowdAnki.from_repr(HeadersFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_HEADERS - )) - note_models_all_ca = NoteModelsAllFromCrowdAnki.from_repr(NoteModelsAllFromCrowdAnki.Representation( - source=folder - )) - notes_ca = NotesFromCrowdAnki.from_repr(NotesFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_NOTES - )) - media_group_ca = MediaGroupFromCrowdAnki.from_repr(MediaGroupFromFolder.Representation( - source=folder, part_id=RECIPE_MEDIA - )) - return headers_ca, note_models_all_ca, notes_ca, media_group_ca - - @staticmethod - def setup_repo_structure(): - create_path_if_not_exists(LOC_RECIPES) - create_path_if_not_exists(LOC_BUILD) - create_path_if_not_exists(LOC_DATA) - create_path_if_not_exists(LOC_HEADERS) - create_path_if_not_exists(LOC_NOTE_MODELS) - create_path_if_not_exists(LOC_MEDIA) diff --git a/brain_brew/commands/run_recipe/__init__.py b/brain_brew/commands/run_recipe/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/run_recipe/build_task.py b/brain_brew/commands/run_recipe/build_task.py deleted file mode 100644 index a95d128..0000000 --- a/brain_brew/commands/run_recipe/build_task.py +++ /dev/null @@ -1,45 +0,0 @@ -from abc import ABCMeta, abstractmethod -from typing import Dict, Type, Set, Optional - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class BuildTask(YamlRepr, object, metaclass=ABCMeta): - execute_immediately: bool = False - accepts_list_of_self: bool = True - rep: Optional[RepresentationBase] - - def encode_rep(self) -> Dict[str, any]: - return self.rep.encode() - - @abstractmethod - def execute(self): - pass - - @classmethod - def task_regex(cls) -> str: - return cls.task_name() - - @classmethod - def get_all_task_regex(cls, subclasses: Set[Type['BuildTask']]) -> Dict[str, Type['BuildTask']]: - task_regex_matches: Dict[str, Type[BuildTask]] = {} - - for sc in subclasses: - if sc.task_regex in task_regex_matches: - raise KeyError(f"Multiple instances of task regex '{sc.task_regex}'") - elif sc.task_regex == "" or sc.task_regex is None: - raise KeyError(f"Unknown task regex in {sc.__name__}") - - task_regex_matches.setdefault(sc.task_regex(), sc) - - # logging.debug(f"Known build tasks: {known_build_tasks}") - return task_regex_matches - - -class TopLevelBuildTask(BuildTask, metaclass=ABCMeta): - pass - - -class BuildPartTask(BuildTask, metaclass=ABCMeta): - execute_immediately: bool = True diff --git a/brain_brew/commands/run_recipe/parts_builder.py b/brain_brew/commands/run_recipe/parts_builder.py deleted file mode 100644 index 0b3fa23..0000000 --- a/brain_brew/commands/run_recipe/parts_builder.py +++ /dev/null @@ -1,64 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_model_single_from_crowd_anki import NoteModelSingleFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.from_yaml_part import NotesFromYamlPart, MediaGroupFromYamlPart -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_html_parts import NoteModelFromHTMLParts -from brain_brew.commands.run_recipe.build_task import BuildTask, BuildPartTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder - - -@dataclass -class PartsBuilder(RecipeBuilder, TopLevelBuildTask): - tasks: List[BuildPartTask] - accepts_list_of_self: bool = False - - @classmethod - def task_name(cls) -> str: - return r'build_parts' - - @classmethod - def task_regex(cls) -> str: - return r'build_parts?' - - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - return BuildPartTask.get_all_task_regex(cls.yamale_dependencies()) - - @classmethod - def from_repr(cls, data: List[dict]): - if not isinstance(data, list): - raise TypeError(f"PartsBuilder needs a list") - return cls.from_list(data) - - def encode(self) -> dict: - pass - - def encode_rep(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_schema(cls) -> str: - return cls.build_yamale_root_node(cls.yamale_dependencies()) - - @classmethod - def yamale_dependencies(cls) -> Set[Type[BuildPartTask]]: - return { - NotesFromCsvs, - NotesFromYamlPart, HeadersFromYamlPart, NoteModelsFromYamlPart, MediaGroupFromYamlPart, - MediaGroupFromFolder, - NoteModelFromHTMLParts, NoteModelsFromYamlPart, NoteModelSingleFromCrowdAnki, NoteModelsAllFromCrowdAnki, - HeadersFromCrowdAnki, MediaGroupFromCrowdAnki, NotesFromCrowdAnki - } diff --git a/brain_brew/commands/run_recipe/recipe_builder.py b/brain_brew/commands/run_recipe/recipe_builder.py deleted file mode 100644 index a4d2f87..0000000 --- a/brain_brew/commands/run_recipe/recipe_builder.py +++ /dev/null @@ -1,82 +0,0 @@ -import re -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from textwrap import indent -from typing import Dict, List, Type, Set - -from brain_brew.commands.run_recipe.build_task import BuildTask -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class RecipeBuilder(YamlObject, metaclass=ABCMeta): - tasks: List[BuildTask] - - def tasks_to_encoded(self) -> list: - return [{task.task_name(): task.encode_rep()} for task in self.tasks] - - @classmethod - def from_list(cls, data: List[dict]): - tasks = cls.read_tasks(data) - return cls( - tasks=tasks - ) - - @classmethod - @abstractmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - pass - - @classmethod - def build_yamale_root_node(cls, subclasses: Set[Type['BuildTask']]) -> str: - task_list = [] - for c in sorted(subclasses, key=lambda x: x.task_name()): - task_command = f"any(include('{c.task_name()}'), list(include('{c.task_name()}')))"\ - if c.accepts_list_of_self else f"include('{c.task_name()}')" - task_list.append(f"map({task_command}, key=regex('{c.task_regex()}', ignore_case=True))") - - final_tasks: str = "list(\n" + indent(",\n".join(task_list), ' ') + "\n)\n" - - return final_tasks - - @classmethod - def read_tasks(cls, tasks: List[dict]) -> list: - task_regex_matches = cls.known_task_dict() - build_tasks = [] - - def find_matching_task(task_n): - for regex, task_to_run in task_regex_matches.items(): - if re.match(regex, task_n, re.RegexFlag.IGNORECASE): - return task_to_run - return None - - # Tasks - for task in tasks: - task_keys = list(task.keys()) - if len(task_keys) != 1: - raise KeyError(f"Task should only contain 1 entry, but contains {task_keys} instead. " - f"Missing list separator '-'?", task) - - task_name = task_keys[0] - task_arguments = task[task_keys[0]] - - matching_task = find_matching_task(task_name) - if matching_task is not None: - if matching_task.accepts_list_of_self and isinstance(task_arguments, list): - task_or_tasks = [matching_task.from_repr(t_arg) for t_arg in task_arguments] - else: - task_or_tasks = [matching_task.from_repr(task_arguments)] - - for inner_task in task_or_tasks: - build_tasks.append(inner_task) - if inner_task.execute_immediately: - inner_task.execute() - else: - raise KeyError(f"Unknown task '{task_name}'") # TODO: check this first on all and return all errors - - return build_tasks - - def execute(self): - for task in self.tasks: - if not task.execute_immediately: - task.execute() diff --git a/brain_brew/commands/run_recipe/run_recipe.py b/brain_brew/commands/run_recipe/run_recipe.py deleted file mode 100644 index 0ed18f7..0000000 --- a/brain_brew/commands/run_recipe/run_recipe.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from brain_brew.interfaces.command import Command -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.configuration.yaml_verifier import YamlVerifier - - -@dataclass -class RunRecipe(Command): - recipe_file_name: str - verify_only: bool - - def execute(self): - # Parse Build Config File - YamlVerifier() - recipe = TopLevelBuilder.parse_and_read(self.recipe_file_name, self.verify_only) - - if not self.verify_only: - recipe.execute() diff --git a/brain_brew/commands/run_recipe/top_level_builder.py b/brain_brew/commands/run_recipe/top_level_builder.py deleted file mode 100644 index 4650f54..0000000 --- a/brain_brew/commands/run_recipe/top_level_builder.py +++ /dev/null @@ -1,87 +0,0 @@ -from textwrap import indent, dedent -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import BuildTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class TopLevelBuilder(YamlRepr, RecipeBuilder): - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - values = TopLevelBuildTask.get_all_task_regex(cls.yamale_dependencies()) - return values - - @classmethod - def build_yamale(cls): - separator = '\n---\n' - top_level = cls.yamale_dependencies() - - builder: List[str] = [cls.build_yamale_root_node(top_level), separator] - - def to_sorted_yamale_string(lines: Set[Type[BuildTask]]): - return [f'''{line.task_name()}:\n{indent(dedent(line.yamale_schema()), ' ')}''' - for line in sorted(lines, key=lambda x: x.task_name())] - - # Schema - builder += to_sorted_yamale_string(top_level) - - builder.append(separator) - - # Dependencies - def resolve_dependencies(deps: Set[Type[BuildTask]]) -> Set[Type[BuildTask]]: - result = set() - for d in deps: - result.add(d) - result = result.union(resolve_dependencies(d.yamale_dependencies())) - return result - - children = resolve_dependencies(cls.yamale_dependencies()) - builder += to_sorted_yamale_string({dep for dep in children if dep not in top_level}) - - return '\n'.join(builder) - - @classmethod - def parse_and_read(cls, filename, verify_only: bool) -> 'TopLevelBuilder': - recipe_data = cls.read_to_dict(filename) - - from brain_brew.configuration.yaml_verifier import YamlVerifier - YamlVerifier.get_instance().verify_recipe(filename) - - if verify_only: - return None - - return cls.from_list(recipe_data) - - @classmethod - def task_name(cls) -> str: - pass - - @classmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def from_repr(cls, data: dict): - pass - - def encode(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_dependencies(cls) -> Set[Type[TopLevelBuildTask]]: - return { - PartsBuilder, - CrowdAnkiGenerate, CsvsGenerate, - GenerateGuidsInCsvs, SaveMediaGroupsToFolder, SaveNoteModelsToFolder - } diff --git a/brain_brew/configuration/__init__.py b/brain_brew/configuration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/configuration/anki_field.py b/brain_brew/configuration/anki_field.py deleted file mode 100644 index 878e0ac..0000000 --- a/brain_brew/configuration/anki_field.py +++ /dev/null @@ -1,16 +0,0 @@ -class AnkiField: - name: str - anki_name: str - default_value: any - - def __init__(self, anki_name, name=None, default_value=None): - self.anki_name = anki_name - self.name = name if name is not None else anki_name - self.default_value = default_value - - def append_name_if_differs(self, dict_to_add_to: dict, value): - if value != self.default_value: - dict_to_add_to.setdefault(self.name, value) - - def does_differ(self, value): - return value != self.default_value diff --git a/brain_brew/configuration/file_manager.py b/brain_brew/configuration/file_manager.py deleted file mode 100644 index f99e31f..0000000 --- a/brain_brew/configuration/file_manager.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Dict, Union - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.yaml.yaml_object import YamlObject - - -class FileManager: - __instance = None - - known_files_dict: Dict[str, SourceFile] - known_parts: Dict[str, PartHolder[YamlObject]] - - def __init__(self): - if FileManager.__instance is None: - FileManager.__instance = self - else: - raise Exception("Multiple FileManagers created") - - self.known_files_dict = {} - self.known_parts = {} - - @staticmethod - def get_instance() -> 'FileManager': - return FileManager.__instance - - @staticmethod - def clear_instance(): - if FileManager.__instance: - FileManager.__instance = None - - # Source Files - - def register_file(self, full_path, file): - if full_path in self.known_files_dict: - raise FileExistsError(f"File already known to FileManager, cannot be registered twice: {full_path}") - self.known_files_dict.setdefault(full_path, file) - - def file_if_exists(self, file_location) -> Union[SourceFile, None]: - if file_location in self.known_files_dict.keys(): - return self.known_files_dict[file_location] - return None - - # Deck Parts - - def register_part(self, dp: PartHolder) -> PartHolder: - if dp.part_id in self.known_parts: - raise KeyError(f"Cannot use same name '{dp.part_id}' for multiple Deck Parts") - self.known_parts.setdefault(dp.part_id, dp) - return dp - - def get_part_if_exists(self, dp_name) -> Union[PartHolder[YamlObject], None]: - return self.known_parts.get(dp_name) - - def get_part(self, name: str): - if name not in self.known_parts: - raise KeyError(f"Cannot find Deck Part '{name}'") - return self.known_parts[name] diff --git a/brain_brew/configuration/part_holder.py b/brain_brew/configuration/part_holder.py deleted file mode 100644 index 5140849..0000000 --- a/brain_brew/configuration/part_holder.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional, TypeVar, Generic - -T = TypeVar('T') - - -@dataclass -class PartHolder(Generic[T]): - part_id: str - save_to_file: Optional[str] - part: T - - file_manager = None - - @classmethod - def get_file_manager(cls): - if not cls.file_manager: - from brain_brew.configuration.file_manager import FileManager - cls.file_manager = FileManager.get_instance() - return cls.file_manager - - @classmethod - def from_file_manager(cls, part_id: str) -> T: - return cls.get_file_manager().get_part(part_id) - - @classmethod - def override_or_create(cls, part_id: str, save_to_file: Optional[str], part: T): - fm = cls.get_file_manager() - - dp = fm.get_part_if_exists(part_id) - if dp is None: - dp = fm.register_part(PartHolder(part_id, save_to_file, part)) - else: - logging.warning(f"Overwriting existing Deck Part '{part_id}'") - dp.part = part - dp.save_to_file = save_to_file - - dp.write_to_file() - - return dp - - def write_to_file(self): - if self.save_to_file is not None: - self.part.dump_to_yaml(self.save_to_file) diff --git a/brain_brew/configuration/representation_base.py b/brain_brew/configuration/representation_base.py deleted file mode 100644 index 08453c9..0000000 --- a/brain_brew/configuration/representation_base.py +++ /dev/null @@ -1,28 +0,0 @@ -import inspect -import logging - - -class RepresentationBase: - @classmethod - def from_dict(cls, data: dict): - expected_values = { - k: v for k, v in data.items() - if k in inspect.signature(cls).parameters - } - - if len(expected_values) != len(data): - logging.warning(f"Unexpected values found when creating '{cls.__name__}': " - f"{[k for k, v in data.items() if k not in list(expected_values.keys())]}" - "\n!!! Please report this error if it seems strange") - - return cls(**expected_values) - - def encode(self): - return {key: value for key, value in self.__dict__.items() if self.encode_filter(key, value)} - - def encode_filter(self, key, value): - if value is None: - return False - if not value: - return False - return True diff --git a/brain_brew/configuration/yaml_verifier.py b/brain_brew/configuration/yaml_verifier.py deleted file mode 100644 index 89f1bbf..0000000 --- a/brain_brew/configuration/yaml_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import os - -import yamale -from yamale import YamaleError -from yamale.schema import Schema -from yamale.validators import DefaultValidators - -validators = DefaultValidators.copy() - - -class YamlVerifier: - __instance = None - recipe_schema: Schema - - def __init__(self): - if YamlVerifier.__instance is None: - YamlVerifier.__instance = self - else: - raise Exception("Multiple YamlVerifiers created") - - path = os.path.join(os.path.dirname(__file__), "../schemas/recipe.yaml") - self.recipe_schema = yamale.make_schema(path, parser='ruamel', validators=validators) - - @staticmethod - def get_instance() -> 'YamlVerifier': - return YamlVerifier.__instance - - def verify_recipe(self, filename): - data = yamale.make_data(filename) - try: - yamale.validate(self.recipe_schema, data) - except YamaleError as e: - print('Validation failed!\n') - for result in e.results: - print("Error validating data '%s' with '%s'\n\t" % (result.data, result.schema)) - for error in result.errors: - print('\t%s' % error) - exit(1) - logging.info(f"Builder file {filename} is ✔ good") diff --git a/brain_brew/front_matter.py b/brain_brew/front_matter.py deleted file mode 100644 index e7b0e27..0000000 --- a/brain_brew/front_matter.py +++ /dev/null @@ -1,2 +0,0 @@ -def latest_version_number(): - return "0.3.11" diff --git a/brain_brew/interfaces/__init__.py b/brain_brew/interfaces/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/interfaces/command.py b/brain_brew/interfaces/command.py deleted file mode 100644 index 3c25a23..0000000 --- a/brain_brew/interfaces/command.py +++ /dev/null @@ -1,7 +0,0 @@ -from abc import ABC, abstractmethod - - -class Command(ABC): - @abstractmethod - def execute(self): - pass diff --git a/brain_brew/interfaces/media_container.py b/brain_brew/interfaces/media_container.py deleted file mode 100644 index 11c7acc..0000000 --- a/brain_brew/interfaces/media_container.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Set - - -class MediaContainer(ABC): - @abstractmethod - def get_all_media_references(self) -> Set[str]: - pass diff --git a/brain_brew/interfaces/yamale_verifyable.py b/brain_brew/interfaces/yamale_verifyable.py deleted file mode 100644 index fdf99eb..0000000 --- a/brain_brew/interfaces/yamale_verifyable.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod - - -class YamlRepr(ABC): - @classmethod - @abstractmethod - def task_name(cls) -> str: - pass - - @classmethod - @abstractmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def yamale_dependencies(cls) -> set: - return set() - - @classmethod - @abstractmethod - def from_repr(cls, data: dict): - pass diff --git a/brain_brew/main.py b/brain_brew/main.py deleted file mode 100644 index 5a36fd8..0000000 --- a/brain_brew/main.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from brain_brew.commands.argument_reader import BBArgumentReader -# sys.path.append(os.path.join(os.path.dirname(__file__), "dist")) -# sys.path.append(os.path.dirname(__file__)) -from brain_brew.configuration.file_manager import FileManager - - -def main(): - logging.basicConfig(level=logging.DEBUG) - - # Read in Arguments - argument_reader = BBArgumentReader() - command = argument_reader.get_parsed() - - # Create Singleton FileManager - FileManager() - - command.execute() - - -if __name__ == "__main__": - main() diff --git a/brain_brew/representation/__init__.py b/brain_brew/representation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/__init__.py b/brain_brew/representation/generic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/csv_file.py b/brain_brew/representation/generic/csv_file.py deleted file mode 100644 index 1f0633d..0000000 --- a/brain_brew/representation/generic/csv_file.py +++ /dev/null @@ -1,116 +0,0 @@ -import csv -from pathlib import Path -import re -import logging -from enum import Enum -from typing import List, Optional - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import create_path_if_not_exists, list_of_str_to_lowercase, sort_dict - -_encoding = "utf-8" - - -class CsvKeys(Enum): - GUID = "guid" - TAGS = "tags" - - -class CsvFile(SourceFile): - file_location: str = "" - _data: List[dict] = [] - column_headers: list = [] - delimiter: str = ',' - - def __init__(self, file, delimiter=None): - self.file_location = file - self.set_delimiter(delimiter) - - def set_delimiter(self, delimiter: str): - if delimiter: - self.delimiter = delimiter - elif re.match(r'.*\.tsv', self.file_location, re.RegexFlag.IGNORECASE): - self.delimiter = '\t' - - @classmethod - def from_file_loc(cls, file_loc) -> 'CsvFile': - return cls(file_loc) - - def read_file(self, create_if_not_exists: Optional[bool] = True): - self._data = [] - - if create_if_not_exists: - create_path_if_not_exists(self.file_location) - Path(self.file_location).touch() - - with open(self.file_location, mode='r', newline='', encoding=_encoding) as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter) - - self.column_headers = list_of_str_to_lowercase(csv_reader.fieldnames) - - for row in csv_reader: - self._data.append({key.lower(): row[key] for key in row}) - - def write_file(self): - logging.info(f"Writing to Csv '{self.file_location}'") - with open(self.file_location, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=self.column_headers, lineterminator='\n', delimiter=self.delimiter) - - csv_writer.writeheader() - - for row in self._data: - csv_writer.writerow(row) - - def set_data(self, data_override): - self._data = data_override - self.column_headers = list(data_override[0].keys()) if data_override else [] - - def set_data_from_superset(self, superset: List[dict], column_header_override=None): - if column_header_override: - self.column_headers = column_header_override - - data_to_set: List[dict] = [] - for row in superset: - if not all(column in row for column in self.column_headers): - continue - new_row = {} - for column in self.column_headers: - new_row[column] = row[column] - data_to_set.append(new_row) - - self._data = data_to_set - - - def get_data(self, deep_copy=False) -> List[dict]: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def to_filename_csv(filename: str, delimiter: str = None) -> str: - if not re.match(r'.*\.(csv|tsv)', filename, re.RegexFlag.IGNORECASE): - if delimiter == '\t': - return filename + '.tsv' - else: - return filename + ".csv" - return filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_csv(location) - - def sort_data(self, sort_by_keys, reverse_sort, case_insensitive_sort): - self._data = sort_dict(self._data, sort_by_keys, reverse_sort, case_insensitive_sort) - - @classmethod - def create_file_with_headers(cls, filepath: str, headers: List[str], delimiter: str = None): - with open(filepath, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=headers, lineterminator='\n', delimiter=delimiter or ",") - - csv_writer.writeheader() - - @staticmethod - def delimiter_matches_file_type(delimiter: str, filename: str) -> bool: - if delimiter == '\t' and re.match(r'.*\.tsv', filename, re.RegexFlag.IGNORECASE): - return True - if delimiter == ',' and re.match(r'.*\.csv', filename, re.RegexFlag.IGNORECASE): - return True - return False diff --git a/brain_brew/representation/generic/html_file.py b/brain_brew/representation/generic/html_file.py deleted file mode 100644 index 7db12e3..0000000 --- a/brain_brew/representation/generic/html_file.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.generic.source_file import SourceFile - -_encoding = "utf-8" - -@dataclass -class HTMLFile(SourceFile): - file_location: str - _data: str - - def __init__(self, file): - self.file_location = file - self.read_file() - - @classmethod - def from_file_loc(cls, file_loc) -> 'HTMLFile': - return cls(file_loc) - - def read_file(self): - r = open(self.file_location, 'r', encoding=_encoding) - self._data = r.read() - - def get_data(self, deep_copy=False) -> str: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def write_file(file_location, data): - with open(file_location, "w+", encoding=_encoding) as file: - file.write(data) - - @staticmethod - def to_filename_html(filename: str) -> str: - return filename + ".html" if not filename.endswith(".html") else filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_html(location) diff --git a/brain_brew/representation/generic/media_file.py b/brain_brew/representation/generic/media_file.py deleted file mode 100644 index d1e71e9..0000000 --- a/brain_brew/representation/generic/media_file.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import shutil -from dataclasses import dataclass, field - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import filename_from_full_path - - -@dataclass -class MediaFile(SourceFile): - file_path: str - filename: str = field(init=False) - - def __post_init__(self): - self.filename = filename_from_full_path(self.file_path) - - @classmethod - def from_file_loc(cls, file_loc) -> 'MediaFile': - return cls(file_loc) - - def __repr__(self): - return f"MediaFile({self.file_path})" - - def __hash__(self): - return hash(self.__repr__()) - - def copy_self_to_target(self, target: str): - shutil.copy2(self.file_path, target) - - def delete_self(self): - os.remove(self.file_path) diff --git a/brain_brew/representation/generic/source_file.py b/brain_brew/representation/generic/source_file.py deleted file mode 100644 index 96231eb..0000000 --- a/brain_brew/representation/generic/source_file.py +++ /dev/null @@ -1,41 +0,0 @@ -import copy -from pathlib import Path - - -class SourceFile(object): - @classmethod - def from_file_loc(cls, file_loc) -> 'SourceFile': - pass - - @classmethod - def is_file(cls, filename: str): - return Path(filename).is_file() - - @classmethod - def is_dir(cls, folder_name: str): - return Path(folder_name).is_dir() - - @classmethod - def get_deep_copy(cls, data): - return copy.deepcopy(data) - - @classmethod - def create_or_get(cls, location): - from brain_brew.configuration.file_manager import FileManager - _file_manager = FileManager.get_instance() - formatted_location = cls.formatted_file_location(location) - file = _file_manager.file_if_exists(formatted_location) - - if file is not None: - return file - - # if not cls.is_file(location) and not cls.is_dir(location): - # raise FileNotFoundError(f"No file or folder '{location}' exists") - - file = cls.from_file_loc(location) - _file_manager.register_file(formatted_location, file) - return file - - @classmethod - def formatted_file_location(cls, location): - return location diff --git a/brain_brew/representation/json/__init__.py b/brain_brew/representation/json/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/json/crowd_anki_export.py b/brain_brew/representation/json/crowd_anki_export.py deleted file mode 100644 index 5652049..0000000 --- a/brain_brew/representation/json/crowd_anki_export.py +++ /dev/null @@ -1,63 +0,0 @@ -import glob -import logging -from typing import List - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.json.json_file import JsonFile -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.utils import create_path_if_not_exists - - -class CrowdAnkiExport(SourceFile): - folder_location: str - json_file_location: str - # import_config: CrowdAnkiImportConfig # TODO: Make this - json_data: CrowdAnkiJsonWrapper - note_models: List[NoteModel] - - media_loc: str - - def __init__(self, folder_location): - self.folder_location = folder_location - if self.folder_location[-1] != "/": - self.folder_location = self.folder_location + "/" - - create_path_if_not_exists(self.folder_location) - - self.json_file_location = self.find_json_file_in_folder() - self._read_json_file() - - self.media_loc = self.folder_location + "media/" - - if not self.is_dir(self.media_loc): - create_path_if_not_exists(self.media_loc) - return - - @classmethod - def from_file_loc(cls, file_loc) -> 'CrowdAnkiExport': - return cls(file_loc) - - def find_json_file_in_folder(self): - files = glob.glob(f"{glob.escape(self.folder_location)}*.json") - - if len(files) == 1: - return files[0] - elif not files: - file_loc = self.folder_location + "deck.json" - logging.warning(f"Creating missing json file '{file_loc}'") - return file_loc - else: - logging.error(f"Multiple json files found in '{self.folder_location}': {files}") - raise FileExistsError() - - def write_to_files(self, json_data): # import_config_data - JsonFile.write_file(self.json_file_location, json_data) - - def _read_json_file(self): - if SourceFile.is_file(self.json_file_location): - self.json_data = CrowdAnkiJsonWrapper(JsonFile.read_file(self.json_file_location)) - self.note_models = list(map(NoteModel.from_crowdanki, self.json_data.note_models)) - else: - self.write_to_files({}) - self.json_data = CrowdAnkiJsonWrapper({}) diff --git a/brain_brew/representation/json/json_file.py b/brain_brew/representation/json/json_file.py deleted file mode 100644 index c1ccfba..0000000 --- a/brain_brew/representation/json/json_file.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -_encoding = "utf-8" - - -class JsonFile: - @staticmethod - def pretty_print(data): - return json.dumps(data, indent=4) - - @staticmethod - def to_filename_json(filename: str): - if filename[-5:] != ".json": - return filename + ".json" - return filename - - @staticmethod - def read_file(file_location): - with open(JsonFile.to_filename_json(file_location), "r", encoding=_encoding) as read_file: - return json.load(read_file) - - @staticmethod - def write_file(file_location, data): - with open(JsonFile.to_filename_json(file_location), "w+", encoding=_encoding) as write_file: - json.dump(data, write_file, indent=4, sort_keys=False, ensure_ascii=False) diff --git a/brain_brew/representation/json/wrappers_for_crowd_anki.py b/brain_brew/representation/json/wrappers_for_crowd_anki.py deleted file mode 100644 index 26c6e5b..0000000 --- a/brain_brew/representation/json/wrappers_for_crowd_anki.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import List - - -CA_NOTE_MODELS = "note_models" -CA_NOTES = "notes" -CA_MEDIA_FILES = "media_files" -CA_CHILDREN = "children" -CA_TYPE = "__type__" -CA_NAME = "name" -CA_DESCRIPTION = "desc" -CA_UUID = "crowdanki_uuid" - -NOTE_MODEL = "note_model_uuid" -FLAGS = "flags" -GUID = "guid" -TAGS = "tags" -FIELDS = "fields" - - -class CrowdAnkiJsonWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def children(self) -> list: - return self.data.get(CA_CHILDREN, []) - - @property - def note_models(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTE_MODELS) - - - @note_models.setter - def note_models(self, value: list): - self.data[CA_NOTE_MODELS] = value - - @property - def notes(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTES) - - @notes.setter - def notes(self, value: list): - self.data[CA_NOTES] = value - - @property - def media_files(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_MEDIA_FILES) - - @media_files.setter - def media_files(self, value: list): - self.data[CA_MEDIA_FILES] = value - - @property - def name(self) -> list: - return self.data.get(CA_NAME, []) - - @name.setter - def name(self, value: list): - self.data[CA_NAME] = value - - @staticmethod - def get_from_self_and_children_recursively(data: dict, running_data: list, key_name: str): - running_data += data.get(key_name, []) - children = data.get(CA_CHILDREN, []) - if isinstance(children, list): - for child in children: - running_data = CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(child, running_data, key_name) - return running_data - - -class CrowdAnkiNoteWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def note_model(self) -> str: - return self.data.get(NOTE_MODEL) - - @note_model.setter - def note_model(self, value: str): - self.data[NOTE_MODEL] = value - - @property - def flags(self) -> int: - return self.data.get(FLAGS) - - @flags.setter - def flags(self, value: int): - self.data[FLAGS] = value - - @property - def guid(self) -> str: - return self.data.get(GUID) - - @guid.setter - def guid(self, value: str): - self.data[GUID] = value - - @property - def tags(self) -> list: - return self.data.get(TAGS, []) - - @tags.setter - def tags(self, value: list): - self.data[TAGS] = value - - @property - def fields(self) -> List[str]: - return self.data.get(FIELDS, []) - - @fields.setter - def fields(self, value: List[str]): - self.data[FIELDS] = value diff --git a/brain_brew/representation/yaml/__init__.py b/brain_brew/representation/yaml/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/yaml/headers.py b/brain_brew/representation/yaml/headers.py deleted file mode 100644 index dd27e7b..0000000 --- a/brain_brew/representation/yaml/headers.py +++ /dev/null @@ -1,44 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NAME, CA_DESCRIPTION, CA_UUID -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class Headers(YamlObject): - data: dict - - @classmethod - def from_yaml_file(cls, filename: str): - return cls(data=cls.read_to_dict(filename)) - - def encode(self) -> dict: - return self.data - - @property - def name(self) -> str: - return self.data[CA_NAME] - - @name.setter - def name(self, desc: str): - self.data[CA_NAME] = desc - - @property - def description(self) -> str: - return self.data.get(CA_DESCRIPTION, "") - - @description.setter - def description(self, desc: str): - self.data[CA_DESCRIPTION] = desc - - @property - def crowdanki_uuid(self) -> str: - return self.data.get(CA_UUID, "") - - @crowdanki_uuid.setter - def crowdanki_uuid(self, desc: str): - self.data[CA_UUID] = desc - - @property - def data_without_name(self) -> dict: - return {k: v for k, v in sorted(self.data.items()) if k != CA_NAME} diff --git a/brain_brew/representation/yaml/media_group.py b/brain_brew/representation/yaml/media_group.py deleted file mode 100644 index eb26334..0000000 --- a/brain_brew/representation/yaml/media_group.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass -from typing import Set, Dict, List, Tuple - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_all_files_in_directory - - -@dataclass -class MediaGroup(YamlObject): - media_files: Dict[str, MediaFile] - - def encode(self) -> list: - return list(m.file_path for m in self.media_files.values()) # TODO: Use relative path for directory? - - @classmethod - def from_yaml_file(cls, filename: str) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(cls.read_to_dict(filename))) - - @classmethod - def from_directory(cls, directory: str, recursive: bool) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(find_all_files_in_directory(directory, recursive=recursive))) - - @classmethod - def from_many(cls, groups: List['MediaGroup']) -> 'MediaGroup': - files = list(set(file.file_path for group in groups for file in group.media_files.values())) - return cls(media_files=cls.from_full_path_list(files)) - - @staticmethod - def from_full_path_list(known_files: list): - files: Dict[str, MediaFile] = dict() - - for full_path in known_files: - file = MediaFile.create_or_get(full_path) - if file.filename not in files.keys(): - files[file.filename] = file - else: - raise NameError(f"Duplicate files with same filename '{file.filename}' in group") - - return files - - def remove_by_filename(self, filename: str): - self.media_files.pop(filename, None) - - def filter_by_filenames(self, filenames: List[str], should_match: bool): - for media_filename in self.media_files.keys(): - is_match = media_filename in filenames - if is_match != should_match: - self.remove_by_filename(media_filename) - # TODO: Find all missing files - - def compare(self, other: 'MediaGroup') -> Tuple[Set[str], Set[str], Set[str]]: - - self_set = set(self.media_files) - other_set = set(other.media_files) - - return self_set.intersection(other_set), self_set - other_set, other_set - self_set diff --git a/brain_brew/representation/yaml/note_model.py b/brain_brew/representation/yaml/note_model.py deleted file mode 100644 index 74c5ee9..0000000 --- a/brain_brew/representation/yaml/note_model.py +++ /dev/null @@ -1,270 +0,0 @@ -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import List, Union, Dict, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import list_of_str_to_lowercase - -# CrowdAnki -CROWDANKI_ID = AnkiField("crowdanki_uuid", "id") -CROWDANKI_TYPE = AnkiField("__type__", default_value="NoteModel") - -# Shared -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") - -# Note Model -CSS = AnkiField("css") -LATEX_PRE = AnkiField("latexPre", "latex_pre", - default_value="\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{" - "amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{" - "document}\n") -LATEX_POST = AnkiField("latexPost", "latex_post", default_value="\\end{document}") -LATEX_SVG = AnkiField("latexsvg", "latex_svg", default_value=False) -REQUIRED_FIELDS_PER_TEMPLATE = AnkiField("req", "required_fields_per_template", default_value=[]) -FIELDS = AnkiField("flds", "fields") -TEMPLATES = AnkiField("tmpls", "templates") -TAGS = AnkiField("tags", default_value=[]) -SORT_FIELD_NUM = AnkiField("sortf", "sort_field_num", default_value=0) -IS_CLOZE = AnkiField("type", "is_cloze", default_value=False) -VERSION = AnkiField("vers", "version", default_value=[]) - -# Field -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - -# Template -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) - - -CSS_FILE = AnkiField("css_file") - - -@dataclass -class NoteModel(YamlObject, YamlRepr, MediaContainer): - @classmethod - def task_name(cls) -> str: - return r"note_model_from_yaml_repr_inner" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - {NAME.name}: str() - {CROWDANKI_ID.name}: str() - {CSS_FILE.name}: str() - {FIELDS.name}: include({Field.task_name()}, required=False) - {TEMPLATES.name}: include({Template.task_name()}, required=False) - {REQUIRED_FIELDS_PER_TEMPLATE.name}: list(required=False) - {LATEX_POST.name}: str(required=False) - {LATEX_PRE.name}: str(required=False) - {SORT_FIELD_NUM.name}: int(required=False) - {IS_CLOZE.name}: bool(required=False) - {CROWDANKI_TYPE.name}: str(required=False) - {TAGS.name}: str(required=False) - {VERSION.name}: list(required=False) - """ - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field, Template} - - @dataclass - class Representation(RepresentationBase): - name: str - id: str - css_file: str - fields: List[dict] - templates: List[dict] - - required_fields_per_template: List[list] = field(default_factory=lambda: []) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - fields=[Field.from_repr(f) for f in rep.fields], - templates=[Template.from_html_files(t) for t in rep.templates], - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=False), - - name=rep.name, is_cloze=bool(rep.is_cloze), - latex_pre=rep.latex_pre, latex_post=rep.latex_post, latex_svg=rep.latex_svg, - required_fields_per_template=rep.required_fields_per_template, - tags=rep.tags, sort_field_num=rep.sort_field_num, version=rep.version, - id=rep.id, crowdanki_type=rep.crowdanki_type - ) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - crowdanki_uuid: str - css: str - flds: List[dict] - tmpls: List[dict] - req: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latexPre: str = field(default=LATEX_PRE.default_value) - latexPost: str = field(default=LATEX_POST.default_value) - latexsvg: bool = field(default=LATEX_SVG.default_value) # TODO: Fix lowercase here in CrowdAnki - __type__: str = field(default=CROWDANKI_TYPE.default_value) - tags: List[str] = field(default_factory=lambda: TAGS.default_value) - sortf: int = field(default=SORT_FIELD_NUM.default_value) - type: int = field(default=0) # Is_Cloze Manually set to 0 - vers: list = field(default_factory=lambda: VERSION.default_value) - - rep: Union[Representation, CrowdAnki] - - name: str - id: str - css: str - fields: List[Field] - templates: List[Template] - - required_fields_per_template: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_yaml_file(cls, filename: str): - data = cls.read_to_dict(filename) - return cls.from_repr(data) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): # TODO: field_whitelist, note_model_whitelist - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - rep=ca, - fields=[Field.from_crowd_anki(f) for f in ca.flds], - templates=[Template.from_crowdanki(t) for t in ca.tmpls], - is_cloze=bool(ca.type), - name=ca.name, css=ca.css, latex_pre=ca.latexPre, latex_post=ca.latexPost, latex_svg=ca.latexsvg, - required_fields_per_template=ca.req, tags=ca.tags, sort_field_num=ca.sortf, version=ca.vers, - id=ca.crowdanki_uuid, crowdanki_type=ca.__type__ - ) - - def encode_as_crowdanki(self) -> dict: - data_dict = { - NAME.anki_name: self.name, - CROWDANKI_ID.anki_name: self.id, - CSS.anki_name: self.css, - REQUIRED_FIELDS_PER_TEMPLATE.anki_name: self.required_fields_per_template, - LATEX_PRE.anki_name: self.latex_pre, - LATEX_POST.anki_name: self.latex_post, - LATEX_SVG.anki_name: self.latex_svg, - SORT_FIELD_NUM.anki_name: self.sort_field_num, - CROWDANKI_TYPE.anki_name: self.crowdanki_type, - TAGS.anki_name: self.tags, - VERSION.anki_name: self.version, - IS_CLOZE.anki_name: 1 if self.is_cloze else 0 - } - - data_dict.setdefault(FIELDS.anki_name, [f.encode_as_crowdanki(num) for num, f in enumerate(self.fields)]) - data_dict.setdefault(TEMPLATES.anki_name, [t.encode_as_crowdanki(num) for num, t in enumerate(self.templates)]) - - return OrderedDict(sorted(data_dict.items())) - - def encode_as_part_with_empty_file_references(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS_FILE.name: "" - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode_as_part() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - REQUIRED_FIELDS_PER_TEMPLATE.append_name_if_differs(data_dict, self.required_fields_per_template) - - return data_dict - - def encode(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS.name: self.css - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - data_dict.setdefault(REQUIRED_FIELDS_PER_TEMPLATE.name, self.required_fields_per_template) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for template in self.templates: - all_media = all_media.union(template.get_all_media_references()) - - return all_media - - @property - def field_names_lowercase(self): - return list_of_str_to_lowercase([f.name for f in self.fields]) - - def check_field_overlap(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - missing = [f for f in self.field_names_lowercase if f not in fields_to_check] - - return missing - - def check_field_extra(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - return [f for f in fields_to_check if f not in self.field_names_lowercase] - - def zip_field_to_data(self, data: List[str]) -> dict: - if len(self.fields) != len(data): - raise Exception( - f"Data of length {len(data)} cannot map to fields of length {len(self.field_names_lowercase)}", data, self.field_names_lowercase) - return dict(zip(self.field_names_lowercase, data)) - - diff --git a/brain_brew/representation/yaml/note_model_field.py b/brain_brew/representation/yaml/note_model_field.py deleted file mode 100644 index eebf277..0000000 --- a/brain_brew/representation/yaml/note_model_field.py +++ /dev/null @@ -1,86 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - - -@dataclass -class Field(RepresentationBase, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"note_model_field" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - """ - - @classmethod - def from_repr(cls, data: dict): - return cls.from_dict(data) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - ord: int = field(default=None) - font: str = field(default=FONT.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) - rtl: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - size: int = field(default=FONT_SIZE.default_value) - sticky: bool = field(default=IS_STICKY.default_value) - - name: str - font: str = field(default=FONT.default_value) - is_right_to_left: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - font_size: int = field(default=FONT_SIZE.default_value) - is_sticky: bool = field(default=IS_STICKY.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) # Unused in Anki - - @classmethod - def from_crowd_anki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, font=ca.font, media=ca.media, - is_right_to_left=ca.rtl, font_size=ca.size, is_sticky=ca.sticky - ) - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - FONT.anki_name: self.font, - MEDIA.anki_name: self.media, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - IS_RIGHT_TO_LEFT.anki_name: self.is_right_to_left, - FONT_SIZE.anki_name: self.font_size, - IS_STICKY.anki_name: self.is_sticky - } - - return data_dict - - def encode_as_part(self) -> dict: - data_dict = { - NAME.name: self.name - } - - FONT.append_name_if_differs(data_dict, self.font) - MEDIA.append_name_if_differs(data_dict, self.media) - IS_RIGHT_TO_LEFT.append_name_if_differs(data_dict, self.is_right_to_left) - FONT_SIZE.append_name_if_differs(data_dict, self.font_size) - IS_STICKY.append_name_if_differs(data_dict, self.is_sticky) - - return data_dict diff --git a/brain_brew/representation/yaml/note_model_template.py b/brain_brew/representation/yaml/note_model_template.py deleted file mode 100644 index 2fa1fb8..0000000 --- a/brain_brew/representation/yaml/note_model_template.py +++ /dev/null @@ -1,196 +0,0 @@ -import os -from dataclasses import dataclass, field -from typing import Optional, Union, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field, split_by_regex - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) -BROWSER_FONT = AnkiField("bfont", "browser_font", default_value="") -BROWSER_FONT_SIZE = AnkiField("bsize", "browser_font_size", default_value=0) -SCRATCH_PAD = AnkiField("scratchPad", "scratch_pad", default_value=0) - -HTML_FILE = AnkiField("html_file") -BROWSER_HTML_FILE = AnkiField("browser_html_file", default_value=None) - -html_separator_regex = r'(?:\r\n|\r|\n){1,}[-]{1,}(?:\r\n|\r|\n){1,}' - - -@dataclass -class Template(RepresentationBase, YamlObject, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_template_from_html' - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - html_file: str() - browser_html_file: str(required=False) - deck_override_id: int(required=False) - """ - - @dataclass - class HTML(RepresentationBase): - name: str - html_file: str - browser_html_file: Optional[str] = field(default=None) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - @classmethod - def from_repr(cls, data: Union[HTML, dict]): - rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - return cls.from_html_files(rep) - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Template': - return cls.from_dict(cls.read_to_dict(filename)) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - qfmt: str - afmt: str - bqfmt: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - bafmt: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - bfont: str = field(default=BROWSER_FONT.default_value) - bsize: int = field(default=BROWSER_FONT_SIZE.default_value) - ord: int = field(default=None) - did: Optional[int] = field(default=None) - scratchPad: int = field(default=SCRATCH_PAD.default_value) - - name: str - question_format: str - answer_format: str - question_format_in_browser: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - answer_format_in_browser: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - html_file: Optional[str] = field(default="") - browser_html_file: Optional[str] = field(default="") - - @classmethod - def from_html_files(cls, data: Union[HTML, dict]): - html_rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - - html_file = HTMLFile.create_or_get(html_rep.html_file) - browser_html_file = HTMLFile.create_or_get(html_rep.browser_html_file) if html_rep.browser_html_file else None - - main_data = html_file.get_data(deep_copy=True) - browser_data = browser_html_file.get_data(deep_copy=True) if browser_html_file else None - - def split_template(the_data, file): - split = split_by_regex(the_data, html_separator_regex) - if len(split) != 2: - raise ValueError(f"Cannot find" if len(split) < 2 else "More than one" - f" separator '---' in html file '{file.file_location}'") - return split[0], split[1] - - front, back = split_template(main_data, html_file) - browser_front, browser_back = split_template(browser_data, browser_html_file) if browser_data else ("", "") - - return cls( - name=html_rep.name, - question_format=front, - answer_format=back, - question_format_in_browser=browser_front, - answer_format_in_browser=browser_back, - deck_override_id=html_rep.deck_override_id, - html_file=html_rep.html_file, - browser_html_file=html_rep.browser_html_file, - browser_font=html_rep.browser_font, - browser_font_size=html_rep.browser_font_size, - scratch_pad=html_rep.scratch_pad, - ) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, question_format=ca.qfmt, answer_format=ca.afmt, - question_format_in_browser=ca.bqfmt, answer_format_in_browser=ca.bafmt, - deck_override_id=ca.did, browser_font=ca.bfont, browser_font_size=ca.bsize, scratch_pad=ca.scratchPad, - ) - - def encode_as_part(self): - data_dict = { - NAME.name: self.name, - HTML_FILE.name: "" - } - - if self.has_browser_template(): - data_dict.setdefault(BROWSER_HTML_FILE.name, "") - - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - ANSWER_FORMAT.anki_name: self.answer_format, - BROWSER_ANSWER_FORMAT.anki_name: self.answer_format_in_browser, - BROWSER_FONT.anki_name: self.browser_font, - BROWSER_QUESTION_FORMAT.anki_name: self.question_format_in_browser, - BROWSER_FONT_SIZE.anki_name: self.browser_font_size, - DECK_OVERRIDE_ID.anki_name: self.deck_override_id, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - QUESTION_FORMAT.anki_name: self.question_format, - SCRATCH_PAD.anki_name: self.scratch_pad, - } - - return data_dict - - def encode(self) -> dict: - data_dict = { - NAME.name: self.name, - QUESTION_FORMAT.name: self.question_format, - ANSWER_FORMAT.name: self.answer_format - } - - BROWSER_QUESTION_FORMAT.append_name_if_differs(data_dict, self.question_format_in_browser) - BROWSER_ANSWER_FORMAT.append_name_if_differs(data_dict, self.answer_format_in_browser) - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() \ - .union(find_media_in_field(self.question_format)) \ - .union(find_media_in_field(self.answer_format)) \ - .union(find_media_in_field(self.question_format_in_browser)) \ - .union(find_media_in_field(self.answer_format_in_browser)) - return all_media - - def has_browser_template(self): - return BROWSER_QUESTION_FORMAT.does_differ(self.question_format_in_browser) \ - or BROWSER_ANSWER_FORMAT.does_differ(self.answer_format_in_browser) - - def get_template_files_data(self): - template = f"{self.question_format}\n\n--\n\n{self.answer_format}" - browser_template = f"{self.question_format}\n\n--\n\n{self.answer_format}" if self.has_browser_template() else None - - return template, browser_template diff --git a/brain_brew/representation/yaml/notes.py b/brain_brew/representation/yaml/notes.py deleted file mode 100644 index ae90dcd..0000000 --- a/brain_brew/representation/yaml/notes.py +++ /dev/null @@ -1,184 +0,0 @@ -import logging -from abc import ABCMeta -from dataclasses import dataclass -from typing import List, Optional, Dict, Set - -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field - -FIELDS = 'fields' -GUID = 'guid' -TAGS = 'tags' -NOTE_MODEL = 'note_model' -FLAGS = "flags" -NOTES = "notes" -NOTE_GROUPINGS = "note_groupings" -MEDIA_REFERENCES = "media_references" - - -@dataclass -class GroupableNoteData(YamlObject, MediaContainer, metaclass=ABCMeta): - note_model: Optional[str] - tags: Optional[List[str]] - - def encode_groupable(self, data_dict): - if self.note_model is not None: - data_dict.setdefault(NOTE_MODEL, self.note_model) - if self.tags is not None and self.tags != []: - data_dict.setdefault(TAGS, self.tags) - return data_dict - - -@dataclass -class Note(GroupableNoteData): - @classmethod - def from_yaml_file(cls, filename: str) -> 'Note': - return cls.from_dict(cls.read_to_dict(filename)) - - fields: List[str] - guid: str - flags: int - # media_references: Optional[Set[str]] - - @classmethod - def from_dict(cls, data: dict): - return cls( - fields=data.get(FIELDS), - guid=data.get(GUID), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None), - flags=data.get(FLAGS, 0) - ) - - def encode(self) -> dict: - data_dict: Dict[str, any] = {FIELDS: self.fields, GUID: self.guid} - if self.flags is not None and self.flags != 0: - data_dict.setdefault(FLAGS, self.flags) - super().encode_groupable(data_dict) - return data_dict - - def get_all_media_references(self) -> Set[str]: - return {entry for field in self.fields for entry in find_media_in_field(field)} - - -@dataclass -class NoteGrouping(GroupableNoteData): - notes: List[Note] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'NoteGrouping': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls( - notes=list(map(Note.from_dict, data.get(NOTES))), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None) - ) - - def encode(self) -> dict: - data_dict = {} - super().encode_groupable(data_dict) - data_dict.setdefault(NOTES, [note.encode() for note in self.notes]) - return data_dict - - # TODO: Extract Shared Tags and Note Models - - def verify_groupings(self): - errors = [] - if self.note_model is not None: - if any([note.note_model for note in self.notes]): - errors.append(ValueError(f"NoteGrouping for 'note_model' {self.note_model} has notes with 'note_model'." - f" Please remove one of these.")) - return errors - - def get_all_known_note_model_names(self) -> set: - return {self.note_model} if self.note_model else {note.note_model for note in self.notes} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note in self.notes: - media = note.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes(self, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - def sort_method(i: Note): - def get_sort_tuple(attr_or_field): - if attr_or_field in [GUID, FLAGS, NOTE_MODEL, TAGS]: - value = getattr(i, attr_or_field) - elif isinstance(attr_or_field, int) and attr_or_field < len(i.fields): - value = i.fields[attr_or_field] - else: - value = "" - logging.warning(f"No known sort value for {attr_or_field}") - - if not isinstance(value, str): - return True, False - return (value == "", value.lower()) if case_insensitive_sort else (value == "", value) - - return tuple(get_sort_tuple(column) for column in sort_by_keys) - - return sorted(self.notes, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(self.notes)) - - return self.notes - - def get_all_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort) -> List[Note]: - def join_tags(n_tags): - if self.tags is None and n_tags is None: - return [] - elif self.tags is None: - return n_tags - elif n_tags is None: - return self.tags - else: - return [*n_tags, *self.tags] - - return [Note( - note_model=self.note_model or n.note_model, - tags=join_tags(n.tags), - fields=n.fields, - guid=n.guid, - flags=n.flags - # media_references=n.media_references or n.get_media_references() - ) for n in self.get_sorted_notes(sort_by_keys, reverse_sort, case_insensitive_sort)] - - -@dataclass -class Notes(YamlObject, MediaContainer): - note_groupings: List[NoteGrouping] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Notes': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls(note_groupings=list(map(NoteGrouping.from_dict, data.get(NOTE_GROUPINGS)))) - - @classmethod - def from_list_of_notes(cls, notes: List[Note]): - return cls(note_groupings=[NoteGrouping(note_model=None, tags=None, notes=notes)]) # TODO: Check grouping here - - def encode(self) -> dict: - data_dict = {NOTE_GROUPINGS: [note_grouping.encode() for note_grouping in self.note_groupings]} - return data_dict - - def get_all_known_note_model_names(self): - return {nms for group in self.note_groupings for nms in group.get_all_known_note_model_names()} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note_group in self.note_groupings: - media = note_group.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort): - return [note for group in self.note_groupings - for note in group.get_all_notes_copy(sort_by_keys, reverse_sort, case_insensitive_sort)] diff --git a/brain_brew/representation/yaml/yaml_object.py b/brain_brew/representation/yaml/yaml_object.py deleted file mode 100644 index 3a05198..0000000 --- a/brain_brew/representation/yaml/yaml_object.py +++ /dev/null @@ -1,55 +0,0 @@ -from abc import ABC, abstractmethod -from pathlib import Path - -from ruamel.yaml import YAML - -from brain_brew.utils import create_path_if_not_exists - -yaml_load = YAML(typ='safe') - - -yaml_dump = YAML() -yaml_dump.preserve_quotes = False -yaml_dump.indent(mapping=2, sequence=2, offset=0) -yaml_dump.representer.ignore_aliases = lambda *data: True -# yaml.sort_base_mapping_type_on_output = False - - -class YamlObject(ABC): - @staticmethod - def read_to_dict(filename: str): - filename = YamlObject.to_filename_yaml(filename) - - if not Path(filename).is_file(): - raise FileNotFoundError(filename) - - with open(filename) as file: - return yaml_load.load(file) - - @staticmethod - def to_filename_yaml(filename: str): - if filename[-5:] != ".yaml" and filename[-4:] != ".yml": - return filename + ".yaml" - return filename - - @abstractmethod - def encode(self) -> dict: - pass - - @classmethod - @abstractmethod - def from_yaml_file(cls, filename: str) -> 'YamlObject': - pass - - def dump_to_yaml(self, filepath): - self.dump_to_yaml_file(filepath, self.encode()) - - @classmethod - def dump_to_yaml_file(cls, filepath, data): - filepath = YamlObject.to_filename_yaml(filepath) - - create_path_if_not_exists(filepath) - - with open(filepath, 'w') as fp: - yaml_dump.dump(data, fp) - diff --git a/brain_brew/schemas/__init__.py b/brain_brew/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/schemas/recipe.yaml b/brain_brew/schemas/recipe.yaml deleted file mode 100644 index 6cf5ca7..0000000 --- a/brain_brew/schemas/recipe.yaml +++ /dev/null @@ -1,173 +0,0 @@ -list( - map(include('build_parts'), key=regex('build_parts?', ignore_case=True)), - map(any(include('generate_crowd_anki'), list(include('generate_crowd_anki'))), key=regex('generate_crowd_anki', ignore_case=True)), - map(any(include('generate_csvs'), list(include('generate_csvs'))), key=regex('generate_csvs?', ignore_case=True)), - map(any(include('generate_guids_in_csvs'), list(include('generate_guids_in_csvs'))), key=regex('generate_guids_in_csvs?', ignore_case=True)), - map(any(include('save_media_groups_to_folder'), list(include('save_media_groups_to_folder'))), key=regex('save_media_groups?_to_folder', ignore_case=True)), - map(any(include('save_note_models_to_folder'), list(include('save_note_models_to_folder'))), key=regex('save_note_models?_to_folder', ignore_case=True)) -) - - ---- - -build_parts: - list( - map(any(include('headers_from_crowd_anki'), list(include('headers_from_crowd_anki'))), key=regex('headers?_from_crowd_anki', ignore_case=True)), - map(any(include('headers_from_yaml_part'), list(include('headers_from_yaml_part'))), key=regex('headers?_from_yaml_part', ignore_case=True)), - map(any(include('media_group_from_crowd_anki'), list(include('media_group_from_crowd_anki'))), key=regex('media_group_from_crowd_anki', ignore_case=True)), - map(any(include('media_group_from_folder'), list(include('media_group_from_folder'))), key=regex('media_group_from_folder', ignore_case=True)), - map(any(include('media_group_from_yaml_part'), list(include('media_group_from_yaml_part'))), key=regex('media_group_from_yaml_part', ignore_case=True)), - map(any(include('note_model_from_crowd_anki'), list(include('note_model_from_crowd_anki'))), key=regex('note_model_from_crowd_anki', ignore_case=True)), - map(any(include('note_model_from_html_parts'), list(include('note_model_from_html_parts'))), key=regex('note_model_from_html_parts', ignore_case=True)), - map(any(include('note_models_all_from_crowd_anki'), list(include('note_models_all_from_crowd_anki'))), key=regex('note_models_all_from_crowd_anki', ignore_case=True)), - map(any(include('note_models_from_yaml_part'), list(include('note_models_from_yaml_part'))), key=regex('note_models?_from_yaml_part', ignore_case=True)), - map(any(include('notes_from_crowd_anki'), list(include('notes_from_crowd_anki'))), key=regex('notes_from_crowd_anki', ignore_case=True)), - map(any(include('notes_from_csvs'), list(include('notes_from_csvs'))), key=regex('notes_from_csvs?', ignore_case=True)), - map(any(include('notes_from_yaml_part'), list(include('notes_from_yaml_part'))), key=regex('notes_from_yaml_part', ignore_case=True)) - ) - -generate_crowd_anki: - folder: str() - headers: str() - notes: include('notes_to_crowd_anki') - note_models: include('note_models_to_crowd_anki') - media: include('media_group_to_crowd_anki', required=False) - -generate_csvs: - notes: str() - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -generate_guids_in_csvs: - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - -save_media_groups_to_folder: - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - -save_note_models_to_folder: - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - - ---- - -file_mapping: - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('file_mapping'), required=False) - delimiter: str(required=False) - -headers_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - -headers_from_yaml_part: - part_id: str() - file: str() - override: include('headers_override', required=False) - -headers_override: - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - -media_group_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_folder: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_yaml_part: - part_id: str() - file: str() - -media_group_to_crowd_anki: - parts: list(str()) - -note_model_field: - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - -note_model_from_crowd_anki: - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_from_html_parts: - part_id: str() - model_id: str() - css_file: str() - fields: list(include('note_model_field')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_mapping: - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - -note_models_all_from_crowd_anki: - source: str() - -note_models_from_yaml_part: - part_id: str() - file: str() - -note_models_to_crowd_anki: - parts: list(include('note_models_to_crowd_anki_item')) - -note_models_to_crowd_anki_item: - part_id: str() - -notes_from_crowd_anki: - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - -notes_from_csvs: - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -notes_from_yaml_part: - part_id: str() - file: str() - -notes_override: - note_model: str(required=False) - -notes_to_crowd_anki: - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('notes_override', required=False) - case_insensitive_sort: bool(required=False) diff --git a/brain_brew/transformers/__init__.py b/brain_brew/transformers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/transformers/create_media_group_from_location.py b/brain_brew/transformers/create_media_group_from_location.py deleted file mode 100644 index ee09946..0000000 --- a/brain_brew/transformers/create_media_group_from_location.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.media_group import MediaGroup - - -def create_media_group_from_location( - part_id: str, - save_to_file: str, - media_group: MediaGroup, - groups_to_blacklist: List[MediaContainer], - groups_to_whitelist: List[MediaContainer] -) -> MediaGroup: - if groups_to_whitelist: - white = list(set.union(*[container.get_all_media_references() for container in groups_to_whitelist])) - media_group.filter_by_filenames(white, should_match=True) - - if groups_to_blacklist: - black = list(set.union(*[container.get_all_media_references() for container in groups_to_blacklist])) - media_group.filter_by_filenames(black, should_match=False) - - holder = PartHolder.override_or_create(part_id, save_to_file, media_group) - return holder.part diff --git a/brain_brew/transformers/file_mapping.py b/brain_brew/transformers/file_mapping.py deleted file mode 100644 index 644dd9d..0000000 --- a/brain_brew/transformers/file_mapping.py +++ /dev/null @@ -1,194 +0,0 @@ -import re - -import logging -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.csv_file import CsvFile, CsvKeys -from brain_brew.utils import single_item_to_list - -FILE = "csv_file" -NOTE_MODEL = "note_model" -SORT_BY_COLUMNS = "sort_by_columns" -REVERSE_SORT = "reverse_sort" -DERIVATIVES = "derivatives" - - -@dataclass -class FileMappingDerivative(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'file_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('{cls.task_name()}'), required=False) - delimiter: str(required=False) - ''' - - @dataclass(init=False) - class Representation(RepresentationBase): - file: str - note_model: Optional[str] - sort_by_columns: Optional[Union[list, str]] - reverse_sort: Optional[bool] - derivatives: Optional[List['FileMappingDerivative.Representation']] - delimiter: Optional[str] - - def __init__(self, file, note_model=None, sort_by_columns=None, reverse_sort=None, case_insensitive_sort=None, derivatives=None, delimiter=None): - self.file = file - self.note_model = note_model - self.sort_by_columns = sort_by_columns - self.reverse_sort = reverse_sort - self.case_insensitive_sort = case_insensitive_sort - self.derivatives = list(map(FileMappingDerivative.Representation.from_dict, derivatives)) \ - if derivatives is not None else [] - self.delimiter = delimiter - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and CsvFile.delimiter_matches_file_type(value, self.file): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv = CsvFile.create_or_get(rep.file) - csv.set_delimiter(rep.delimiter) - csv.read_file() - return cls( - rep=rep, - csv_file=csv, - note_model=rep.note_model.strip() if rep.note_model else None, - sort_by_columns=single_item_to_list(rep.sort_by_columns), - reverse_sort=rep.reverse_sort or False, - case_insensitive_sort=rep.case_insensitive_sort or True, - derivatives=list(map(cls.from_repr, rep.derivatives)) if rep.derivatives is not None else [], - ) - - rep: Representation - - compiled_data: Dict[str, dict] = field(init=False) - - csv_file: CsvFile - - note_model: Optional[str] - sort_by_columns: list - reverse_sort: bool - case_insensitive_sort: bool - derivatives: Optional[List['FileMappingDerivative']] - - def get_available_columns(self): - return self.csv_file.column_headers + [col for der in self.derivatives for col in der.get_available_columns()] - - def get_used_note_model_names(self) -> List[str]: - nm = [self.note_model] if self.note_model is not None else [] - return nm + [name for der in self.derivatives for name in der.get_used_note_model_names()] - - def _build_data_recursive(self) -> List[dict]: - data_in_progress = self.csv_file.get_data(deep_copy=True) - - new_columns_seen_so_far = self.csv_file.column_headers.copy() - for der in self.derivatives: - der_cols = der.get_available_columns() - overlapping_cols = [col for col in der_cols if col in self.csv_file.column_headers] - der_cols = [col for col in der_cols if col not in overlapping_cols] - - if not overlapping_cols: - raise KeyError("No column overlap for derivative") - - column_repeat_errors = [KeyError(f"Derivative column {c} in multiple derivative lines") - for c in der_cols if c in new_columns_seen_so_far] - if column_repeat_errors: - raise Exception(column_repeat_errors) - new_columns_seen_so_far += der_cols - - der_match_errors = [] - for der_row in der._build_data_recursive(): - # Find matching row to pair data with - found_match = False - for row in data_in_progress: - if all([der_row[c] == row[c] for c in overlapping_cols]): - for der_col in der_cols: - row[der_col] = der_row[der_col] - found_match = True - # Set Note Model to matching Derivative Note Model - if der.note_model is not None: - row.setdefault(NOTE_MODEL, der.note_model) - break - if not found_match: - der_match_errors.append(ValueError(f"Cannot match derivative row {der_row} to parent")) - - if der_match_errors: - raise Exception(der_match_errors) - - return data_in_progress - - def write_to_csv(self, data_to_set): - self.csv_file.set_data_from_superset(data_to_set) - self.csv_file.sort_data(self.sort_by_columns, self.reverse_sort, self.case_insensitive_sort) - self.csv_file.write_file() - - for der in self.derivatives: - der.write_to_csv(data_to_set) - - -@dataclass -class FileMapping(FileMappingDerivative): - note_model: str # Override Optional on Children - - data_set_has_changed: bool = field(init=False, default=False) - - def compile_data(self): - self.compiled_data = {} - self.data_set_has_changed = False - - data_in_progress = self._build_data_recursive() - - # Set Note Model if not already set - if self.note_model is not None: - for row in data_in_progress: - row.setdefault(NOTE_MODEL, self.note_model) - - for row in data_in_progress: - guid = row[CsvKeys.GUID.value] - if not guid: - raise KeyError("Some rows are missing guids") - self.compiled_data.setdefault(guid, {key.lower(): row[key] for key in row}) - - def set_relevant_data(self, data_set: Dict[str, dict]): - unchanged, changed, added = 0, 0, 0 - for guid in data_set: - if guid in self.compiled_data.keys(): - changed_row = False - for key in data_set[guid]: - if key in self.compiled_data[guid] and self.compiled_data[guid][key] != data_set[guid][key]: - self.compiled_data[guid][key] = data_set[guid][key] - changed_row = True - if changed_row: - changed += 1 - else: - unchanged += 1 - else: - added += 1 - self.compiled_data.setdefault(guid, data_set[guid]) - - if changed > 0 or added > 0: - self.data_set_has_changed = True - - logging.info(f"Set {self.csv_file.file_location} data; changed {changed}, " - f"added {added}, while {unchanged} were identical") - - def write_file_on_close(self): - if self.data_set_has_changed: - self.write_to_csv(list(self.compiled_data.values())) diff --git a/brain_brew/transformers/note_model_mapping.py b/brain_brew/transformers/note_model_mapping.py deleted file mode 100644 index f27dce8..0000000 --- a/brain_brew/transformers/note_model_mapping.py +++ /dev/null @@ -1,178 +0,0 @@ -from dataclasses import dataclass, field -from enum import Enum -from typing import List, Union, Dict, Optional - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.notes import GUID, TAGS -from brain_brew.utils import single_item_to_list - - -class FieldMapping: - class FieldMappingType(Enum): - COLUMN = "column" - PERSONAL_FIELD = "personal_field" - DEFAULT = "default" - - @classmethod - def values(cls): - return set(it.value for it in cls) - - type: FieldMappingType - value: str - field_name: str - - def __init__(self, field_type: FieldMappingType, field_name: str, value: str): - self.type = field_type - self.field_name = field_name.lower() - - if self.type == FieldMapping.FieldMappingType.COLUMN: - self.value = value.lower() - else: - self.value = value - - -@dataclass -class NoteModelMapping(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_models: Union[str, list] - columns_to_fields: Optional[Dict[str, str]] = field(default=None) - personal_fields: List[str] = field(default_factory=lambda: []) - - note_models: Dict[str, PartHolder[NoteModel]] - columns_manually_mapped: List[FieldMapping] - personal_fields: List[FieldMapping] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - note_models = [PartHolder.from_file_manager(model) for model in single_item_to_list(rep.note_models)] - - return cls( - columns_manually_mapped=[FieldMapping( - field_type=FieldMapping.FieldMappingType.COLUMN, - field_name=f, - value=key) for key, f in rep.columns_to_fields.items()] - if rep.columns_to_fields else [], - personal_fields=[FieldMapping( - field_type=FieldMapping.FieldMappingType.PERSONAL_FIELD, - field_name=f, - value="") for f in rep.personal_fields], - note_models=dict(map(lambda nm: (nm.part_id, nm), note_models)) - ) - - def get_note_model_mapping_dict(self): - return {model: self for model in self.note_models} - - def verify_contents(self): - if not self.columns_manually_mapped: # No check needed if no manual mapping is performed - return - - errors = [] - required_field_definitions = [GUID, TAGS] - - extra_fields = [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - - for holder in self.note_models.values(): - model: NoteModel = holder.part - - # Check for Required Fields - missing = [] - for req in required_field_definitions: - if req not in [field.field_name for field in self.columns_manually_mapped]: - missing.append(req) - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Check Fields Align with Note Type - missing = model.check_field_overlap( - [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - ) - missing = [m for m in missing if m not in [field.field_name for field in self.personal_fields]] - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Find mappings which do not exist on any note models - if extra_fields: - extra_fields = model.check_field_extra(extra_fields) - - if extra_fields: - errors.append( - KeyError(f"""Error in note_model_mappings part. \ - Field(s) '{extra_fields}' are defined as mappings, but match no Note Model fields""")) - - if errors: - raise Exception(errors) - - def csv_row_map_to_note_fields(self, row: dict) -> dict: - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - for pf in self.personal_fields: # Add in Personal Fields - relevant_row_data.setdefault(pf.field_name, False) - for column in self.columns_manually_mapped: # Rename from Csv Column to Note Type Field - if column.value in relevant_row_data: - relevant_row_data[column.field_name] = relevant_row_data.pop(column.value) - - return relevant_row_data - - def csv_headers_map_to_note_fields(self, row: list) -> list: - return list(self.csv_row_map_to_note_fields({row_name: "" for row_name in row}).keys()) - - def note_fields_map_to_csv_row(self, row): - for column in self.columns_manually_mapped: # Rename from Note Type Field to Csv Column - if column.field_name in row: - row[column.value] = row.pop(column.field_name) - for pf in self.personal_fields: # Remove Personal Fields - if pf.field_name in row: - del row[pf.field_name] - - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - return relevant_row_data - - def filter_data_row_by_relevant_columns(self, row): - cols = list(row.keys()) - - relevant_columns = [f.value for f in self.columns_manually_mapped] - if not relevant_columns: - return row - - # errors = [KeyError(f"Missing column {rel_col}") for rel_col in relevant_columns if rel_col not in cols] - # if errors: - # raise Exception(errors) - - irrelevant_columns = [column for column in cols if column not in relevant_columns] - if not irrelevant_columns: - return row - - relevant_data = {key: row[key] for key in row if key not in irrelevant_columns} - - return relevant_data - - def field_values_in_note_model_order(self, note_model_name, fields_from_csv): - return [fields_from_csv[f] if f in fields_from_csv else "" - for f in self.note_models[note_model_name].part.field_names_lowercase - ] diff --git a/brain_brew/transformers/save_media_group_to_location.py b/brain_brew/transformers/save_media_group_to_location.py deleted file mode 100644 index fb5e8fb..0000000 --- a/brain_brew/transformers/save_media_group_to_location.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging -from typing import List, Set - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.utils import create_path_if_not_exists - - -def save_media_groups_to_location( - parts: List[MediaGroup], - folder: str, - clear_folder: bool, - recursive: bool -) -> Set[MediaFile]: - - create_path_if_not_exists(folder, is_path_override=True) - - existing_media_group = MediaGroup.from_directory(folder, recursive) - all_media_group = MediaGroup.from_many(parts) - - in_both, to_move, to_delete = all_media_group.compare(existing_media_group) - - for filename, media_file in all_media_group.media_files.items(): - if filename in in_both: - media_file.copy_self_to_target(existing_media_group.media_files[filename].file_path) - # TODO: Check if copying is needed? - elif filename in to_move: - media_file.copy_self_to_target(folder) - - if clear_folder and to_delete: - deleted = '\n'.join(to_delete) - logging.warning(f"Deleting extra files in media folder '{folder}':\n{'-'*20}\n{deleted}\n{'-'*20}") - for delete_name in to_delete: - existing_media_group.media_files[delete_name].delete_self() - - return set(all_media_group.media_files.values()) diff --git a/brain_brew/transformers/save_note_model_to_location.py b/brain_brew/transformers/save_note_model_to_location.py deleted file mode 100644 index 9a7b367..0000000 --- a/brain_brew/transformers/save_note_model_to_location.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging -import os -from typing import List - -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel, CSS_FILE, TEMPLATES -from brain_brew.representation.yaml.note_model_template import HTML_FILE as TEMPLATE_HTML_FILE, NAME as TEMPLATE_NAME, BROWSER_HTML_FILE as TEMPLATE_BROWSER_HTML_FILE -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import create_path_if_not_exists, clear_contents_of_folder - - -def save_note_model_to_location( - model: NoteModel, - folder: str, - clear_folder: bool -) -> str: - - nm_folder = os.path.join(folder, model.name + '/') - create_path_if_not_exists(nm_folder) - - if clear_folder: - clear_contents_of_folder(nm_folder) - - model_encoded = model.encode_as_part_with_empty_file_references() - - model_encoded[CSS_FILE.name] = os.path.join(nm_folder, "style.css") - HTMLFile.write_file(model_encoded[CSS_FILE.name], model.css) - - templates_dict = {t.name: t for t in model.templates} - - for template_data in model_encoded[TEMPLATES.name]: - name = template_data[TEMPLATE_NAME.name] - template = templates_dict[name] - t_data, b_t_data = template.get_template_files_data() - - template_data[TEMPLATE_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name)) - HTMLFile.write_file(template_data[TEMPLATE_HTML_FILE.name], t_data) - - if TEMPLATE_BROWSER_HTML_FILE.name in template_data and b_t_data is not None: - template_data[TEMPLATE_BROWSER_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name + "_browser")) - HTMLFile.write_file(template_data[TEMPLATE_BROWSER_HTML_FILE.name], b_t_data) - - model_yaml_file_name = YamlObject.to_filename_yaml(os.path.join(nm_folder, model.name)) - YamlObject.dump_to_yaml_file(model_yaml_file_name, model_encoded) - - return model_yaml_file_name diff --git a/brain_brew/utils.py b/brain_brew/utils.py deleted file mode 100644 index 2b85f39..0000000 --- a/brain_brew/utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -import os -import random -import re -import shutil -import string -from pathlib import Path -from typing import List - - -def blank_str_if_none(s): - return '' if s is None else s - - -def list_of_str_to_lowercase(list_of_strings): - if not list_of_strings: - return [] - return [entry.lower() for entry in list_of_strings] - - -def single_item_to_list(item): - if isinstance(item, list): - return item - if item is None: - return [] - return [item] - - -def str_to_lowercase_no_separators(str_to_tidy: str): - return re.sub(r'[\s+_-]+', '', str_to_tidy.lower()) - - -def filename_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+$', full_path)[0] - - -def folder_name_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+[/]?$', full_path)[0] - - -def split_by_regex(str_to_split: str, pattern: str) -> List[str]: - return re.split(pattern, str_to_split) - - -def find_media_in_field(field_value: str) -> List[str]: - if not field_value: - return [] - - images = re.findall(r'<\s*?img.*?src="(.*?)"[^>]*?>', field_value) - audio = re.findall(r'\[sound:(.*?)]', field_value) - - return images + audio - - -def find_all_files_in_directory(directory, recursive=False): - found_files = [] - for path, dirs, files in os.walk(directory): - for file in files: - found_files.append(os.path.join(path, file)) - if not recursive: - return found_files - return found_files - - -def create_path_if_not_exists(path, is_path_override=False): - dir_name = os.path.dirname(path) if not is_path_override else path - if not Path(dir_name).is_dir(): - logging.warning(f"Creating missing filepath '{dir_name}'") - os.makedirs(dir_name, exist_ok=True) - - -def clear_contents_of_folder(path): - for filename in os.listdir(path): - file_path = os.path.join(path, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - print('Failed to delete %s. Reason: %s' % (file_path, e)) - - -def split_tags(tags_value: str) -> list: - split = [entry.strip() for entry in re.split(r';\s*|,\s*|\s+', tags_value)] - while "" in split: - split.remove("") - return split - - -def join_tags(tags_list: list) -> str: - return ", ".join(tags_list) # TODO: Make configurable - - -def generate_anki_guid() -> str: - """Return a base91-encoded 64bit random number.""" - - def base62(num: int, extra: str = "") -> str: - s = string - table = s.ascii_letters + s.digits + extra - buf = "" - while num: - num, i = divmod(num, len(table)) - buf = table[i] + buf - return buf - - _base91_extra_chars = "!#$%&()*+,-./:;<=>?@[]^_`{|}~" - - def base91(num: int) -> str: - # all printable characters minus quotes, backslash and separators - return base62(num, _base91_extra_chars) - - return base91(random.randint(0, 2 ** 64 - 1)) - - -def sort_dict(data, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - if case_insensitive_sort: - def sort_method(i): - return tuple((i[column] == "", i[column].lower()) for column in sort_by_keys) - else: - def sort_method(i): - return tuple((i[column] == "", i[column]) for column in sort_by_keys) - - return sorted(data, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(data)) - - return data diff --git a/crates/brain-brew-cli/Cargo.toml b/crates/brain-brew-cli/Cargo.toml new file mode 100644 index 0000000..c92ee76 --- /dev/null +++ b/crates/brain-brew-cli/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "brainbrew" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +readme = "README.md" +authors.workspace = true +repository.workspace = true +description = "Rust-based Brain Brew CLI for local-first Anki-compatible deck federation" +homepage = "https://github.com/jeprecated/brain-brew" + +[package.metadata.dist] +dist = true +formula = "brainbrew" + +[features] +default = [] +# Development/test-only access to unsafe Workbench mutation implementations. +# Normal and distributed release artifacts must not enable this feature. +workbench-write-dev = [] + +[[bin]] +name = "brainbrew" +path = "src/main.rs" + +[dependencies] +axum = "0.8" +base64 = "0.22" +crossterm = "0.28" +flate2 = "1" +fs2 = "0.4" +include_dir = "0.7" +nix-nar = "0.4" +brain-brew-core.workspace = true +brain-brew-formats.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tar = "0.4" +tempfile = "3" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +tower-http = { version = "0.6", features = ["fs"] } +ureq = "2" +url = "2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[lints] +workspace = true diff --git a/crates/brain-brew-cli/LICENSE b/crates/brain-brew-cli/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/crates/brain-brew-cli/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/crates/brain-brew-cli/README.md b/crates/brain-brew-cli/README.md new file mode 100644 index 0000000..3c96ce5 --- /dev/null +++ b/crates/brain-brew-cli/README.md @@ -0,0 +1,5 @@ +# brainbrew + +`brainbrew` is the command-line interface for [Brain Brew](https://github.com/jeprecated/brain-brew), a local-first deck federation tool for shared Anki-compatible decks. + +Install a published preview with `cargo install brainbrew --version 1.0.0-alpha.2 --locked`. See the [project README](https://github.com/jeprecated/brain-brew#readme) for workflows and release guidance. diff --git a/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4.js b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4.js new file mode 100644 index 0000000..7d53ab3 --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4.js @@ -0,0 +1,1032 @@ +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, controller); + return ret; + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, controller); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, reason); + return ret; + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return ret; + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, controller); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_debug_string_8a447059637473e2: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_falsy_863e44ed68df4b26: function(arg0) { + const ret = !arg0; + return ret; + }, + __wbg___wbindgen_is_function_acc5528be2b923f2: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_6d937fbfb6478470: function(arg0) { + const ret = arg0 === null; + return ret; + }, + __wbg___wbindgen_is_undefined_721f8decd50c87a3: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_string_get_71bb4348194e31f0: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_ea4887a5f8f9a9db: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_33c39e13d73b25f6: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_activeElement_39e967783d67360b: function(arg0) { + const ret = arg0.activeElement; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_addEventListener_ea90bc131475777e: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_appendChild_acb7691406591783: function() { return handleError(function (arg0, arg1) { + const ret = arg0.appendChild(arg1); + return ret; + }, arguments); }, + __wbg_body_c94f9310613c153b: function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_buffer_9e4d98d0766fb908: function(arg0) { + const ret = arg0.buffer; + return ret; + }, + __wbg_byobRequest_73c9bc59c55e3ee6: function(arg0) { + const ret = arg0.byobRequest; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_byteLength_564c2b9b251b8b6c: function(arg0) { + const ret = arg0.byteLength; + return ret; + }, + __wbg_byteOffset_10f01f7956bb8fe3: function(arg0) { + const ret = arg0.byteOffset; + return ret; + }, + __wbg_call_5575218572ead796: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_cancelBubble_5b69b645651771ec: function(arg0) { + const ret = arg0.cancelBubble; + return ret; + }, + __wbg_checked_55b8214328709363: function(arg0) { + const ret = arg0.checked; + return ret; + }, + __wbg_cloneNode_631e6c7312338944: function() { return handleError(function (arg0) { + const ret = arg0.cloneNode(); + return ret; + }, arguments); }, + __wbg_cloneNode_9272c316db5e3f99: function() { return handleError(function (arg0, arg1) { + const ret = arg0.cloneNode(arg1 !== 0); + return ret; + }, arguments); }, + __wbg_close_5733609ce25f1cc5: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_close_7d786ceed6cb3df5: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_closest_36aba08ae599365a: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.closest(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_composedPath_def7f121fe83b309: function(arg0) { + const ret = arg0.composedPath(); + return ret; + }, + __wbg_content_f9ccb50e1342a471: function(arg0) { + const ret = arg0.content; + return ret; + }, + __wbg_createComment_7e51040356705a12: function(arg0, arg1, arg2) { + const ret = arg0.createComment(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_createElementNS_9b91843f9092dfaf: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.createElementNS(arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + return ret; + }, arguments); }, + __wbg_createElement_9e23ac95e40e302c: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_createTextNode_f1aff30c8f1e7fff: function(arg0, arg1, arg2) { + const ret = arg0.createTextNode(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_deleteProperty_46503cf4081bd53c: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.deleteProperty(arg0, arg1); + return ret; + }, arguments); }, + __wbg_document_2634180a4c694068: function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_enqueue_0b3f8e4acf60affc: function() { return handleError(function (arg0, arg1) { + arg0.enqueue(arg1); + }, arguments); }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_fetch_8d9b732df7467c44: function(arg0) { + const ret = fetch(arg0); + return ret; + }, + __wbg_firstChild_464bcbc28a75f770: function(arg0) { + const ret = arg0.firstChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_firstElementChild_76be8507bd3e838f: function(arg0) { + const ret = arg0.firstElementChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_focus_c80921c19b28520b: function() { return handleError(function (arg0) { + arg0.focus(); + }, arguments); }, + __wbg_getAttribute_9f23675ef6df0d6b: function(arg0, arg1, arg2, arg3) { + const ret = arg1.getAttribute(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_getElementById_c7aba6b93b34bf01: function(arg0, arg1, arg2) { + const ret = arg0.getElementById(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_getItem_f2b45bf1b0166c48: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg1.getItem(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_get_197a3fe98f169e38: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_37b48b8fa52d1f2c: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_get_dddb90ff5d27a080: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_host_3a9c4a713cc5f742: function(arg0) { + const ret = arg0.host; + return ret; + }, + __wbg_insertBefore_d1c9b9c881ae2c96: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.insertBefore(arg1, arg2); + return ret; + }, arguments); }, + __wbg_instanceof_Element_7cd17aeb1babd05e: function(arg0) { + let result; + try { + result = arg0 instanceof Element; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Error_77d0cf0b4f31a32f: function(arg0) { + let result; + try { + result = arg0 instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlInputElement_5a4ec7d390cf0cfc: function(arg0) { + let result; + try { + result = arg0 instanceof HTMLInputElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlSelectElement_dcf326cd531e9e5d: function(arg0) { + let result; + try { + result = arg0 instanceof HTMLSelectElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Response_79948c98d1d2ba75: function(arg0) { + let result; + try { + result = arg0 instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_ShadowRoot_6a0f2632ea4e6534: function(arg0) { + let result; + try { + result = arg0 instanceof ShadowRoot; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_0d356b88a2f77c42: function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_is_de5b366c746e004c: function(arg0, arg1) { + const ret = Object.is(arg0, arg1); + return ret; + }, + __wbg_item_668a48adee49f9c6: function(arg0, arg1) { + const ret = arg0.item(arg1 >>> 0); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_key_69986dd1df591552: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg1.key(arg2 >>> 0); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_length_589238bdcf171f0e: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_93d87b2ef70e71b2: function() { return handleError(function (arg0) { + const ret = arg0.length; + return ret; + }, arguments); }, + __wbg_length_f7d417ff7066e5e2: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_localStorage_8daa25c913870d2f: function() { return handleError(function (arg0) { + const ret = arg0.localStorage; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_message_6fc0a1f59fcc247b: function(arg0) { + const ret = arg0.message; + return ret; + }, + __wbg_name_3e4efa9f5ec3f127: function(arg0) { + const ret = arg0.name; + return ret; + }, + __wbg_new_10e2f2ad134f940f: function() { return handleError(function () { + const ret = new Headers(); + return ret; + }, arguments); }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return ret; + }, + __wbg_new_26880d0ec07a54ec: function() { return handleError(function (arg0, arg1) { + const ret = new URL(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_2e117a478906f062: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_df50d6b5ba0c6ef5: function() { return handleError(function () { + const ret = new URLSearchParams(); + return ret; + }, arguments); }, + __wbg_new_e66a4b7758dd2e5c: function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_typed_00a409eb4ec4f2d9: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_new_with_byte_offset_and_length_f2b65504a914f37a: function(arg0, arg1, arg2) { + const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_new_with_str_42b7fb3e03fb91e5: function() { return handleError(function (arg0, arg1) { + const ret = new Request(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_with_str_and_init_5b299538bdeeec64: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); + return ret; + }, arguments); }, + __wbg_nextSibling_d25dacde974cb36f: function(arg0) { + const ret = arg0.nextSibling; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_ok_c962da6f1a034d81: function(arg0) { + const ret = arg0.ok; + return ret; + }, + __wbg_parentNode_31eaeb2d9a747844: function(arg0) { + const ret = arg0.parentNode; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_querySelectorAll_ffda3c891a9eb29a: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelectorAll(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_querySelector_1f3658f4b48e268b: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_querySelector_45b96634e7f85460: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_queueMicrotask_1c9b3800e321a967: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_queueMicrotask_311744e534a929a3: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_removeAttribute_f13ae85cfd9fbd58: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_removeEventListener_c6782a9c30557d31: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_removeItem_dbf2a2abce661547: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeItem(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_remove_4e0c46e5ae03c0b0: function(arg0) { + arg0.remove(); + }, + __wbg_remove_627151898f1aafc8: function(arg0) { + arg0.remove(); + }, + __wbg_resolve_d82363d90af6928a: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_respond_ab492bf06dffbd1d: function() { return handleError(function (arg0, arg1) { + arg0.respond(arg1 >>> 0); + }, arguments); }, + __wbg_search_15207b35b594af69: function(arg0, arg1) { + const ret = arg1.search; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_setAttribute_8bccfbabf2a83682: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setItem_ab73a1e4497df37e: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setItem(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_0bf1fca872bc6d18: function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_1c87dcfd4a93c514: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.set(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_4564f7dc44fcb0c9: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(arg0, arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_body_97c25d1c0051cb04: function(arg0, arg1) { + arg0.body = arg1; + }, + __wbg_set_headers_6751c09a8e579ff7: function(arg0, arg1) { + arg0.headers = arg1; + }, + __wbg_set_innerHTML_30fcedd016ac76ab: function(arg0, arg1, arg2) { + arg0.innerHTML = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_method_1120482abe0934aa: function(arg0, arg1, arg2) { + arg0.method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_nodeValue_a5d55d296937f033: function(arg0, arg1, arg2) { + arg0.nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_readOnly_5f5983a529df333e: function(arg0, arg1) { + arg0.readOnly = arg1 !== 0; + }, + __wbg_set_search_048cc0b716fa132a: function(arg0, arg1, arg2) { + arg0.search = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_textContent_5c5fef072bd24f7a: function(arg0, arg1, arg2) { + arg0.textContent = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_value_3cd64c88136b460c: function(arg0, arg1, arg2) { + arg0.value = getStringFromWasm0(arg1, arg2); + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_static_accessor_GLOBAL_THIS_2fee5048bcca5938: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_ce44e66a4935da8c: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_44f6e0cb5e67cdad: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_168f178805d978fe: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_status_0053aa6239760447: function(arg0) { + const ret = arg0.status; + return ret; + }, + __wbg_target_4387d5c508f1ecbd: function(arg0) { + const ret = arg0.target; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_textContent_a5497fd4d40a66b5: function(arg0, arg1) { + const ret = arg1.textContent; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_text_68ea00f7126f2706: function() { return handleError(function (arg0) { + const ret = arg0.text(); + return ret; + }, arguments); }, + __wbg_then_05edfc8a4fea5106: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, + __wbg_then_591b6b3a75ee817a: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbg_toString_73456969ad00f2fa: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_toString_b9fe6ec9b4809cee: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_url_c6a2bdc371c879a8: function(arg0, arg1) { + const ret = arg1.url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_1686b2f0ffb64a18: function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_18f5592a194a3ef2: function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_view_b959961c99705680: function(arg0) { + const ret = arg0.view; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1056, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1118, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1056, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./brain_brew_workbench_ui_bg.js": import0, + }; +} + +function wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8(arg0, arg1, arg2); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(arg0, arg1, arg2, arg3); +} + + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr, 1)); +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr, 1)); +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_destroy_closure(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('brain_brew_workbench_ui_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4_bg.wasm b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4_bg.wasm new file mode 100644 index 0000000..dcdb881 Binary files /dev/null and b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-f0aef0fc4b8956b4_bg.wasm differ diff --git a/crates/brain-brew-cli/assets/workbench/index.html b/crates/brain-brew-cli/assets/workbench/index.html new file mode 100644 index 0000000..9a2b59b --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/index.html @@ -0,0 +1,27 @@ + + + + + + Brain Brew Deck Workbench + + + + +
+ Brain Brew Deck Workbench loading workspace metadata +
+ + + + diff --git a/crates/brain-brew-cli/assets/workbench/workbench-d3eb063ec5c6039.css b/crates/brain-brew-cli/assets/workbench/workbench-d3eb063ec5c6039.css new file mode 100644 index 0000000..fbf26ec --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/workbench-d3eb063ec5c6039.css @@ -0,0 +1,595 @@ +:root { + color-scheme: dark; + --bg: #050811; + --bg-glow: #10264a; + --surface: #0f172a; + --surface-muted: #111827; + --surface-raised: #172033; + --surface-strong: #1f2a44; + --text: #e5edf8; + --text-strong: #f8fafc; + --muted: #97a6ba; + --muted-strong: #bac6d6; + --border: #263348; + --border-strong: #3b4a63; + --accent: #60a5fa; + --accent-strong: #3b82f6; + --accent-contrast: #031025; + --warning: #fbbf24; + --warning-bg: #30240d; + --warning-border: #8a6818; + --success: #86efac; + --success-bg: #0f2a1a; + --success-border: #1f7a3b; + --danger: #f87171; + --danger-bg: #331417; + --danger-border: #7f1d1d; + --shadow: 0 18px 50px rgb(0 0 0 / 0.35); +} + +* { box-sizing: border-box; } + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + background: radial-gradient(circle at 20% -10%, var(--bg-glow) 0, transparent 34rem), var(--bg); + color: var(--text); + font: 0.92rem/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body > *:not(#brainbrew-workbench-e2e):not(#workbench-dom-panel):not(script) { + display: none !important; +} + +[hidden] { display: none !important; } + +button, +input, +select { + font: inherit; +} + +button, +select, +input { + border: 1px solid var(--border-strong); + border-radius: 0.5rem; + background: var(--surface-muted); + color: var(--text); +} + +button { + cursor: pointer; + font-weight: 800; + padding: 0.55rem 0.8rem; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + background: var(--surface-raised); + color: var(--text-strong); +} + +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select { + padding: 0.45rem 0.6rem; +} + +input:focus, +select:focus, +button:focus-visible, +[contenteditable="true"]:focus-visible, +summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +label { + color: var(--muted-strong); + font-size: 0.86rem; + font-weight: 800; +} + +code, +pre { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +#brainbrew-workbench-e2e { + position: fixed; + top: 0.75rem; + right: 0.75rem; + z-index: 5; + max-width: min(48rem, calc(100vw - 1.5rem)); + border: 1px solid var(--border-strong); + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 88%, transparent); + box-shadow: var(--shadow); + color: var(--muted-strong); + font-size: 0.82rem; + padding: 0.35rem 0.8rem; +} + +.workbench-access-banner { + border: 2px solid var(--warning-border); + border-radius: 0.75rem; + background: var(--warning-bg); + color: var(--text); + margin-bottom: 1rem; + padding: 0.85rem 1rem; +} + +.workbench-access-banner.development-write { + border-color: var(--danger-border); + background: var(--danger-bg); +} + +.workbench-access-banner strong { + color: var(--warning); + font-size: 1.05rem; +} + +.workbench-access-banner.development-write strong { + color: var(--danger); +} + +.workbench-access-banner p, +.draft-retention-copy { + margin: 0.35rem 0 0; + color: var(--muted-strong); +} + +#workbench-dom-panel { + position: static; + z-index: 2; + min-height: calc(100vh - 2rem); + overflow: visible; + width: min(1560px, calc(100% - 2rem)); + margin: 1rem auto; + border: 1px solid var(--border); + border-radius: 0.9rem; + background: color-mix(in srgb, var(--surface) 94%, black); + box-shadow: var(--shadow); + color: var(--text); + padding: 1rem; +} + +.workbench-panel__header { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(2, minmax(12rem, 18rem)); + gap: 0.8rem; + align-items: end; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.workbench-panel__header h2 { + margin: 0; + color: var(--text-strong); + font-size: clamp(1.5rem, 3vw, 2.3rem); + letter-spacing: -0.03em; +} + +#translation-progress, +#optional-progress, +.navigation-page-summary, +.note-navigation-more, +.inline-edit-hint { + color: var(--muted); +} + +.overlay-badges, +.pivot-filters, +.card-pivot-filters, +.source-string-filters, +.source-string-global-edit { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.75rem 0; +} + +.overlay-badge, +.pivot-filters button, +.card-pivot-filters button { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + color: var(--muted-strong); + padding: 0.25rem 0.65rem; +} + +.overlay-badge.active, +.pivot-filters button.active, +.card-pivot-filters button.active { + border-color: var(--accent-strong); + background: var(--surface-raised); + color: var(--text-strong); +} + +.pane-layout-panel, +.new-language-panel, +.apply-box, +.note-navigation, +.note-card, +.card-detail, +.source-string-detail, +.lazy-pivot-panel { + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--surface-muted); + padding: 1rem; +} + +.pane-layout-panel, +.new-language-panel { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; + margin: 0.8rem 0; +} + +.pane-layout-panel h3, +.new-language-panel h3 { + width: 100%; + margin: 0; + color: var(--text-strong); +} + +.workbench-view-switch { + position: sticky; + top: 0; + z-index: 4; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 1rem -0.25rem; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: blur(10px); +} + +.workbench-view-button { + background: transparent; + border-color: transparent; + color: var(--muted-strong); +} + +.workbench-view-button.active, +.workbench-view-button[aria-current="page"] { + border-color: var(--border-strong); + background: var(--surface-raised); + color: var(--text-strong); +} + +.workbench-view-stack { + display: grid; + gap: 1rem; +} + +.workbench-view.active { + display: block; +} + +#view-panel-notes.active { + display: grid; + grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.note-navigation { + position: sticky; + top: 4.5rem; + max-height: calc(100vh - 6rem); + overflow: auto; +} + +.note-navigation h3, +.note-card h3, +.card-detail h4, +.lazy-pivot-panel h3 { + margin: 0 0 0.75rem; + color: var(--text-strong); +} + +.note-navigation-list, +.card-list, +.source-string-list { + display: grid; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.note-navigation-row, +.card-row, +.source-string-row { + width: 100%; + border: 1px solid var(--border); + border-radius: 0.6rem; + background: var(--surface); + color: var(--text); + cursor: pointer; + padding: 0.7rem; + text-align: left; +} + +.note-navigation-row strong, +.card-row strong, +.source-string-row strong { + display: block; + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-navigation-row.active, +.card-row.active, +.source-string-row.active { + border-color: var(--accent-strong); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + align-items: start; +} + +.preview-grid > section { + min-width: 0; +} + +.anki-card-preview { + border: 1px solid var(--border); + border-radius: 0.75rem; + margin: 0.7rem 0; + overflow: hidden; + background: #030712; +} + +.card-template-header { + display: flex; + gap: 0.6rem; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + background: var(--surface-raised); + padding: 0.45rem 0.65rem; +} + +.card-template-label { + color: var(--text-strong); + font-weight: 900; +} + +.card-template-header code { + max-width: 50%; + overflow: hidden; + border-radius: 999px; + background: var(--surface-strong); + color: var(--muted-strong); + font-size: 0.72rem; + padding: 0.15rem 0.45rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-side { + display: grid; + gap: 0.45rem; + padding: 0.75rem; +} + +.card-side + .card-side { + border-top: 1px solid var(--border); +} + +.card-side > strong { + color: var(--muted-strong); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inline-target-field, +.card-inline-target-field { + border-radius: 0.35rem; + box-shadow: inset 0 -0.16rem 0 color-mix(in srgb, var(--accent) 45%, transparent); + cursor: text; + padding: 0.05rem 0.12rem; +} + +.inline-target-field:hover, +.card-inline-target-field:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); +} + +.inline-target-field[data-staged="true"], +.card-inline-target-field[data-staged="true"] { + background: var(--success-bg); + box-shadow: 0 0 0 2px var(--success-border); + color: var(--text-strong); +} + +.inline-target-field[contenteditable="false"], +.card-inline-target-field[contenteditable="false"] { + cursor: not-allowed; + opacity: 0.7; +} + +.field-editor-details { + margin-top: 1rem; + border: 1px solid var(--border); + border-radius: 0.65rem; + background: var(--surface); +} + +.field-editor-details summary { + cursor: pointer; + color: var(--text-strong); + font-weight: 900; + padding: 0.75rem; +} + +.field-editor, +.card-field-editor, +.source-string-occurrences { + width: 100%; + border-collapse: collapse; +} + +.field-editor th, +.field-editor td, +.card-field-editor th, +.card-field-editor td, +.source-string-occurrences th, +.source-string-occurrences td { + border-top: 1px solid var(--border); + padding: 0.5rem; + text-align: left; + vertical-align: top; +} + +.field-editor th, +.card-field-editor th, +.source-string-occurrences th { + color: var(--muted-strong); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.field-editor input, +.card-field-editor input, +.source-string-occurrences input, +.source-string-global-edit input { + width: 100%; + min-width: 12rem; +} + +.card-grid, +.source-string-grid { + display: grid; + grid-template-columns: minmax(16rem, 24rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.card-list, +.source-string-list { + max-height: 42rem; + overflow: auto; +} + +.card-detail-panel, +.source-string-detail { + min-width: 0; +} + +.lazy-pivot-panel { + border-style: dashed; + background: color-mix(in srgb, var(--surface-muted) 88%, var(--accent-contrast)); +} + +.lazy-pivot-panel[data-lazy-state="loading"] { + opacity: 0.82; +} + +.lazy-pivot-panel[data-lazy-state="loaded"] { + border-style: solid; + background: transparent; + padding: 0; +} + +.lazy-pivot-load-button, +#apply-preview-button, +#apply-confirm-button, +#load-secondary-pane, +#new-language-preview-button, +#new-language-confirm-button, +#source-string-direct-stage, +#source-string-no-change { + border-color: var(--accent-strong); + background: var(--accent-strong); + color: var(--text-strong); +} + +.lazy-pivot-load-button { + display: block; + width: min(100%, 28rem); +} + +.apply-box { + margin-top: 1rem; +} + +#apply-preview-output { + margin-top: 0.75rem; + max-height: 18rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border); + border-radius: 0.6rem; + background: #030712; + color: var(--text-strong); + padding: 0.8rem; +} + +.workbench-error { + border: 1px solid var(--danger-border); + border-radius: 0.6rem; + background: var(--danger-bg); + color: #fecaca; + padding: 0.75rem; +} + +.workbench-loading { + color: var(--muted-strong); +} + +img { + max-width: 100%; + height: auto; +} + +@media (max-width: 980px) { + .workbench-panel__header, + #view-panel-notes.active, + .preview-grid, + .card-grid, + .source-string-grid { + grid-template-columns: 1fr; + } + + .note-navigation { + position: static; + max-height: none; + } + + #brainbrew-workbench-e2e { + position: static; + margin: 0.75rem auto 0; + width: min(100% - 1rem, 48rem); + } +} diff --git a/crates/brain-brew-cli/src/args.rs b/crates/brain-brew-cli/src/args.rs new file mode 100644 index 0000000..ed74f52 --- /dev/null +++ b/crates/brain-brew-cli/src/args.rs @@ -0,0 +1,522 @@ +use std::path::PathBuf; + +use brain_brew_core::{OverlayKind, StableId}; +use brain_brew_formats::manifest::TranslationCoveragePolicy; + +use crate::media_verification::MediaVerificationMode; +use crate::package_resolver::{DiscoveryPolicy, apply_discovery_option}; + +pub(crate) struct ManifestTargetArgs { + pub(crate) manifest_path: PathBuf, + pub(crate) target: String, + pub(crate) out_path: Option, + pub(crate) force: bool, + pub(crate) media_roots: Vec, + pub(crate) media_mode: MediaVerificationMode, + pub(crate) json_output: bool, + pub(crate) include_paths: Vec, + pub(crate) package_roots: Vec, + pub(crate) discovery_policy: DiscoveryPolicy, +} + +pub(crate) struct VerifyArgs { + pub(crate) manifest_path: PathBuf, + pub(crate) target: Option, + pub(crate) all_targets: bool, + pub(crate) media_roots: Vec, + pub(crate) media_mode: MediaVerificationMode, + pub(crate) json_output: bool, + pub(crate) include_paths: Vec, + pub(crate) package_roots: Vec, + pub(crate) translation_coverage: Option, + pub(crate) skip_content_validation: bool, + pub(crate) discovery_policy: DiscoveryPolicy, +} + +pub(crate) struct ExportArgs { + pub(crate) overlay_paths: Vec, + pub(crate) out_path: Option, + pub(crate) media_root: Option, + pub(crate) media_mode: MediaVerificationMode, + pub(crate) json_output: bool, + pub(crate) force: bool, +} + +pub(crate) struct DiffOverlayArgs { + pub(crate) left_path: PathBuf, + pub(crate) right_path: PathBuf, + pub(crate) id: StableId, + pub(crate) kind: OverlayKind, +} + +pub(crate) struct TargetsArgs { + pub(crate) manifest_paths: Vec, + pub(crate) package_roots: Vec, + pub(crate) discovery_policy: DiscoveryPolicy, +} + +pub(crate) fn split_json_flag(args: &[String]) -> (bool, Vec) { + let mut json_output = false; + let mut rest = Vec::new(); + for arg in args { + if arg == "--json" { + json_output = true; + } else { + rest.push(arg.clone()); + } + } + (json_output, rest) +} + +pub(crate) fn parse_targets_args(args: &[String]) -> Result { + let mut manifest_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut discovery_policy = DiscoveryPolicy::default(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" | "--include" => { + let Some(path) = args.get(index + 1) else { + return Err(format!("{} requires a path", args[index])); + }; + manifest_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + flag @ ("--discovery-max-depth" + | "--discovery-max-entries" + | "--discovery-max-manifests" + | "--package-ignore") => { + apply_discovery_arg(args, index, flag, &mut discovery_policy)?; + index += 2; + } + other => return Err(format!("unexpected targets argument {other:?}")), + } + } + if manifest_paths.is_empty() && package_roots.is_empty() { + manifest_paths.push(PathBuf::from("brainbrew.yaml")); + } + Ok(TargetsArgs { + manifest_paths, + package_roots, + discovery_policy, + }) +} + +pub(crate) fn parse_manifest_target_args(args: &[String]) -> Result { + parse_manifest_target_args_with_force(args, false, false) +} + +pub(crate) fn parse_manifest_target_output_args( + args: &[String], +) -> Result { + parse_manifest_target_args_with_force(args, true, false) +} + +pub(crate) fn parse_manifest_target_export_args( + args: &[String], +) -> Result { + parse_manifest_target_args_with_force(args, true, true) +} + +fn parse_manifest_target_args_with_force( + args: &[String], + allow_force: bool, + allow_media_policy: bool, +) -> Result { + let mut manifest_path = None; + let mut target = None; + let mut out_path = None; + let mut force = false; + let mut media_roots = Vec::new(); + let mut media_mode = MediaVerificationMode::Strict; + let mut media_mode_selected = false; + let mut json_output = false; + let mut include_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut discovery_policy = DiscoveryPolicy::default(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" if manifest_path.is_none() => { + let Some(path) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err("--manifest requires a path".to_owned()); + }; + manifest_path = Some(PathBuf::from(path)); + index += 2; + } + "--target" if target.is_none() => { + let Some(name) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err("--target requires a name".to_owned()); + }; + target = Some(name.clone()); + index += 2; + } + "--out" if out_path.is_none() => { + let Some(path) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err("--out requires a path".to_owned()); + }; + out_path = Some(PathBuf::from(path)); + index += 2; + } + duplicate if matches!(duplicate, "--manifest" | "--target" | "--out") => { + return Err(format!("duplicate argument {duplicate:?}")); + } + "--force" if allow_force && !force => { + force = true; + index += 1; + } + "--force" if allow_force => return Err("duplicate argument \"--force\"".to_owned()), + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a path".to_owned()); + }; + media_roots.push(path.clone()); + index += 2; + } + "--media-mode" if allow_media_policy && !media_mode_selected => { + let Some(mode) = args.get(index + 1) else { + return Err("--media-mode requires strict or reference-only".to_owned()); + }; + media_mode = MediaVerificationMode::parse(mode)?; + media_mode_selected = true; + index += 2; + } + "--media-mode" if allow_media_policy => { + return Err("duplicate argument \"--media-mode\"".to_owned()); + } + "--json" if allow_media_policy && !json_output => { + json_output = true; + index += 1; + } + "--json" if allow_media_policy => { + return Err("duplicate argument \"--json\"".to_owned()); + } + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + include_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + flag @ ("--discovery-max-depth" + | "--discovery-max-entries" + | "--discovery-max-manifests" + | "--package-ignore") => { + apply_discovery_arg(args, index, flag, &mut discovery_policy)?; + index += 2; + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + let Some(target) = target else { + return Err("missing --target".to_owned()); + }; + Ok(ManifestTargetArgs { + manifest_path: manifest_path.unwrap_or_else(|| PathBuf::from("brainbrew.yaml")), + target, + out_path, + force, + media_roots, + media_mode, + json_output, + include_paths, + package_roots, + discovery_policy, + }) +} + +pub(crate) fn parse_verify_args(args: &[String]) -> Result { + let mut manifest_path = None; + let mut target = None; + let mut all_targets = false; + let mut media_roots = Vec::new(); + let mut media_mode = MediaVerificationMode::Strict; + let mut media_mode_selected = false; + let mut json_output = false; + let mut include_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut translation_coverage = None; + let mut skip_content_validation = false; + let mut discovery_policy = DiscoveryPolicy::default(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" => { + let Some(path) = args.get(index + 1) else { + return Err("--manifest requires a path".to_owned()); + }; + manifest_path = Some(PathBuf::from(path)); + index += 2; + } + "--target" => { + let Some(name) = args.get(index + 1) else { + return Err("--target requires a name".to_owned()); + }; + target = Some(name.clone()); + index += 2; + } + "--all-targets" => { + all_targets = true; + index += 1; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a path".to_owned()); + }; + media_roots.push(path.clone()); + index += 2; + } + "--media-mode" if !media_mode_selected => { + let Some(mode) = args.get(index + 1) else { + return Err("--media-mode requires strict or reference-only".to_owned()); + }; + media_mode = MediaVerificationMode::parse(mode)?; + media_mode_selected = true; + index += 2; + } + "--media-mode" => return Err("duplicate argument \"--media-mode\"".to_owned()), + "--json" if !json_output => { + json_output = true; + index += 1; + } + "--json" => return Err("duplicate argument \"--json\"".to_owned()), + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + include_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + flag @ ("--discovery-max-depth" + | "--discovery-max-entries" + | "--discovery-max-manifests" + | "--package-ignore") => { + apply_discovery_arg(args, index, flag, &mut discovery_policy)?; + index += 2; + } + "--translation-coverage" => { + let Some(policy) = args.get(index + 1) else { + return Err("--translation-coverage requires lenient or strict".to_owned()); + }; + translation_coverage = Some(parse_translation_coverage_policy(policy)?); + index += 2; + } + "--skip-content-validation" => { + skip_content_validation = true; + index += 1; + } + other => return Err(format!("unexpected verify argument {other:?}")), + } + } + if all_targets && target.is_some() { + return Err("choose --all-targets or --target, not both".to_owned()); + } + Ok(VerifyArgs { + manifest_path: manifest_path.unwrap_or_else(|| PathBuf::from("brainbrew.yaml")), + target, + all_targets, + media_roots, + media_mode, + json_output, + include_paths, + package_roots, + translation_coverage, + skip_content_validation, + discovery_policy, + }) +} + +fn apply_discovery_arg( + args: &[String], + index: usize, + flag: &str, + policy: &mut DiscoveryPolicy, +) -> Result<(), String> { + let value = args + .get(index + 1) + .ok_or_else(|| format!("{flag} requires a value"))?; + if apply_discovery_option(flag, value, policy)? { + Ok(()) + } else { + Err(format!("unknown discovery option {flag:?}")) + } +} + +fn parse_translation_coverage_policy(value: &str) -> Result { + match value { + "lenient" => Ok(TranslationCoveragePolicy::Lenient), + "strict" => Ok(TranslationCoveragePolicy::Strict), + other => Err(format!( + "invalid translation coverage policy {other:?}; expected lenient or strict" + )), + } +} + +pub(crate) fn parse_overlay_and_optional_out( + args: &[String], +) -> Result<(Vec, Option, bool), String> { + let export_args = parse_overlay_out_media(args)?; + if export_args.media_root.is_some() { + return Err("--media-root is only supported for media-aware commands".to_owned()); + } + if export_args.media_mode != MediaVerificationMode::Strict || export_args.json_output { + return Err( + "--media-mode and --json are only supported for media-aware commands".to_owned(), + ); + } + Ok(( + export_args.overlay_paths, + export_args.out_path, + export_args.force, + )) +} + +pub(crate) fn parse_overlay_out_media(args: &[String]) -> Result { + let mut overlay_paths = Vec::new(); + let mut out_path = None; + let mut media_root = None; + let mut media_mode = MediaVerificationMode::Strict; + let mut media_mode_selected = false; + let mut json_output = false; + let mut force = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--overlay" => { + let Some(path) = args.get(index + 1) else { + return Err("--overlay requires a path".to_owned()); + }; + overlay_paths.push(path.clone()); + index += 2; + } + "--out" if out_path.is_none() => { + let Some(path) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err("--out requires a path".to_owned()); + }; + out_path = Some(PathBuf::from(path)); + index += 2; + } + "--media-root" if media_root.is_none() => { + let Some(path) = args.get(index + 1).filter(|value| !value.starts_with('-')) else { + return Err("--media-root requires a path".to_owned()); + }; + media_root = Some(PathBuf::from(path)); + index += 2; + } + "--media-mode" if !media_mode_selected => { + let Some(mode) = args.get(index + 1) else { + return Err("--media-mode requires strict or reference-only".to_owned()); + }; + media_mode = MediaVerificationMode::parse(mode)?; + media_mode_selected = true; + index += 2; + } + "--json" if !json_output => { + json_output = true; + index += 1; + } + "--force" if !force => { + force = true; + index += 1; + } + duplicate + if matches!( + duplicate, + "--media-root" | "--media-mode" | "--json" | "--force" | "--out" + ) => + { + return Err(format!("duplicate argument {duplicate:?}")); + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + Ok(ExportArgs { + overlay_paths, + out_path, + media_root, + media_mode, + json_output, + force, + }) +} + +pub(crate) fn parse_diff_overlay_args(args: &[String]) -> Result { + let mut paths = Vec::new(); + let mut id = None; + let mut kind = OverlayKind::Patch; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--as-overlay" => index += 1, + "--id" => { + let Some(value) = args.get(index + 1) else { + return Err("--id requires an overlay stable id".to_owned()); + }; + id = Some(stable_id(value)?); + index += 2; + } + "--kind" => { + let Some(value) = args.get(index + 1) else { + return Err("--kind requires an overlay kind".to_owned()); + }; + kind = parse_overlay_kind(value)?; + index += 2; + } + other if !other.starts_with('-') => { + paths.push(PathBuf::from(other)); + index += 1; + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + if paths.len() != 2 { + return Err( + "usage: brainbrew diff --as-overlay --id [--kind patch]" + .to_owned(), + ); + } + let Some(id) = id else { + return Err("diff --as-overlay requires --id".to_owned()); + }; + Ok(DiffOverlayArgs { + left_path: paths.remove(0), + right_path: paths.remove(0), + id, + kind, + }) +} + +fn parse_overlay_kind(value: &str) -> Result { + match value { + "translation" => Ok(OverlayKind::Translation), + "extension" => Ok(OverlayKind::Extension), + "patch" => Ok(OverlayKind::Patch), + "personal" => Ok(OverlayKind::Personal), + other => Err(format!("unknown overlay kind {other:?}")), + } +} + +fn stable_id(value: &str) -> Result { + StableId::new(value).map_err(|error| error.to_string()) +} diff --git a/crates/brain-brew-cli/src/commands/compose.rs b/crates/brain-brew-cli/src/commands/compose.rs new file mode 100644 index 0000000..77d4118 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/compose.rs @@ -0,0 +1,118 @@ +use std::path::Path; + +use brain_brew_formats::canonical_yaml; + +use crate::args::{parse_manifest_target_output_args, parse_overlay_and_optional_out}; +use crate::commands::verify; +use crate::help; +use crate::io::read_deck_and_overlays; +use crate::output; +use crate::planner::plan_manifest_target; +use crate::workspace_mutation::write_output_file; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("compose").expect("compose help exists")); + return Ok(()); + } + + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let manifest_args = parse_manifest_target_output_args(args)?; + let plan = plan_manifest_target( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + &manifest_args.discovery_policy, + )?; + verify::emit_stale_translation_warnings(&plan)?; + let deck = plan.compose().map_err(|report| { + output::compose_error( + "compose", + serde_json::json!({ + "manifest": manifest_args.manifest_path, + "target": manifest_args.target, + }), + &report, + ) + })?; + let yaml = canonical_yaml::to_string(&deck).map_err(|error| error.to_string())?; + if let Some(path) = manifest_args.out_path { + write_canonical_output(&path, yaml.into_bytes(), manifest_args.force)?; + let mut details = vec![ + ( + "manifest", + manifest_args.manifest_path.display().to_string(), + ), + ("target", manifest_args.target.clone()), + ("output", path.display().to_string()), + ]; + details.extend(output::deck_stats(&deck)); + output::print_success( + format!("composed target {}", manifest_args.target), + &details, + ); + } else { + print!("{yaml}"); + } + return Ok(()); + } + + if args.is_empty() { + return Err(help::usage_error( + "compose", + "usage: brainbrew compose [--overlay overlay.yaml ...] [--out resolved.yaml]", + )); + } + if args[0].starts_with('-') { + return Err(format!("unexpected argument {:?}", args[0])); + } + let deck_path = Path::new(&args[0]); + let (overlay_paths, out_path, force) = parse_overlay_and_optional_out(&args[1..])?; + let (base, overlays) = read_deck_and_overlays(deck_path, &overlay_paths)?; + verify::emit_stale_translation_warnings_for_overlays("ad-hoc", &base, &overlays)?; + let deck = base + .compose( + &overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + .map_err(|report| { + output::compose_error( + "compose", + serde_json::json!({ + "source": deck_path, + "overlays": overlay_paths, + }), + &report, + ) + })?; + let yaml = canonical_yaml::to_string(&deck).map_err(|error| error.to_string())?; + if let Some(path) = out_path { + write_canonical_output(&path, yaml.into_bytes(), force)?; + let mut details = vec![ + ("source", deck_path.display().to_string()), + ("overlays", overlay_paths.len().to_string()), + ("output", path.display().to_string()), + ]; + details.extend(output::deck_stats(&deck)); + output::print_success("composed deck", &details); + } else { + print!("{yaml}"); + } + Ok(()) +} + +fn write_canonical_output(path: &Path, bytes: Vec, force: bool) -> Result<(), String> { + write_output_file(path, bytes, force, |replacement| { + let text = std::str::from_utf8(replacement) + .map_err(|error| format!("generated compose output is not UTF-8: {error}"))?; + canonical_yaml::from_str(text) + .map(|_| ()) + .map_err(|error| format!("{}: {error}", path.display())) + }) +} diff --git a/crates/brain-brew-cli/src/commands/diff.rs b/crates/brain-brew-cli/src/commands/diff.rs new file mode 100644 index 0000000..9f43131 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/diff.rs @@ -0,0 +1,55 @@ +use std::path::Path; + +use brain_brew_formats::canonical_yaml; + +use crate::args::parse_diff_overlay_args; +use crate::io::read_deck; +use crate::output::{print_human_diff, print_json_diff}; +use crate::overlay_draft::draft_overlay_from_diff; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.iter().any(|arg| arg == "--as-overlay") { + let overlay_args = parse_diff_overlay_args(args)?; + let left = read_deck(&overlay_args.left_path)?; + let right = read_deck(&overlay_args.right_path)?; + let overlay = draft_overlay_from_diff(&left, &right, overlay_args.id, overlay_args.kind)?; + print!( + "{}", + canonical_yaml::overlay_to_string(&overlay).map_err(|error| error.to_string())? + ); + return Ok(()); + } + + let mut json_output = false; + let mut exit_code = false; + let mut paths = Vec::new(); + for arg in args { + match arg.as_str() { + "--json" if !json_output => json_output = true, + "--exit-code" if !exit_code => exit_code = true, + duplicate if matches!(duplicate, "--json" | "--exit-code") => { + return Err(format!("duplicate diff argument {duplicate:?}")); + } + other if !other.starts_with('-') => paths.push(other.to_owned()), + other => return Err(format!("unexpected argument {other:?}")), + } + } + if paths.len() != 2 { + return Err( + "usage: brainbrew diff [--json] [--exit-code]".to_owned(), + ); + } + let left = read_deck(Path::new(&paths[0]))?; + let right = read_deck(Path::new(&paths[1]))?; + let diff = left.semantic_diff(&right); + if json_output { + print_json_diff(&diff); + } else { + print_human_diff(&diff); + } + if exit_code && !diff.is_empty() { + Err(crate::output::DIFFERENCES_FOUND.to_owned()) + } else { + Ok(()) + } +} diff --git a/crates/brain-brew-cli/src/commands/explain.rs b/crates/brain-brew-cli/src/commands/explain.rs new file mode 100644 index 0000000..c3ae4bb --- /dev/null +++ b/crates/brain-brew-cli/src/commands/explain.rs @@ -0,0 +1,159 @@ +use serde_json::json; + +use crate::args::{parse_manifest_target_args, split_json_flag}; +use crate::output::{self, one_line, package_json, semantic_kind_name}; +use crate::planner::{OverlayExpansionOrigin, TargetExpansionOrigin, plan_manifest_target}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + let (json_output, rest) = split_json_flag(args); + let manifest_args = parse_manifest_target_args(&rest)?; + let plan = plan_manifest_target( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + &manifest_args.discovery_policy, + )?; + + if !json_output { + if let Some(package) = &plan.package { + println!("package: {}@{}", package.id, package.version); + } + println!("target: {}", plan.qualified_name); + println!("base: {}", plan.base_label); + println!("overlay stack:"); + if plan.overlays.is_empty() { + println!(" (none)"); + } else { + for (index, (overlay, _)) in plan.overlays.iter().enumerate() { + println!(" {}. {} ({})", index + 1, overlay.id, overlay.display_file); + } + } + } + + let overlay_stack = plan + .overlays + .iter() + .map(|(overlay, _)| { + let origin = match &overlay.origin { + OverlayExpansionOrigin::Target { + qualified_target, + reference, + } => json!({ + "kind": "target", + "target": qualified_target, + "reference": reference, + }), + OverlayExpansionOrigin::Dependency { + declaring_overlay, + reference, + } => json!({ + "kind": "overlay_dependency", + "overlay": declaring_overlay, + "reference": reference, + }), + }; + json!({ + "id": overlay.id, + "qualified_id": overlay.qualified_id, + "file": overlay.display_file, + "origin": origin, + }) + }) + .collect::>(); + let target_expansion = plan + .target_expansion + .iter() + .map(|target| { + let origin = match &target.origin { + TargetExpansionOrigin::Selection => json!({"kind": "selection"}), + TargetExpansionOrigin::Extends { + declaring_target, + reference, + } => json!({ + "kind": "extends", + "target": declaring_target, + "reference": reference, + }), + }; + json!({ + "qualified_name": target.qualified_name, + "package": target.owner.as_ref().map(|package| json!({ + "id": package.id, + "version": package.version, + })), + "origin": origin, + }) + }) + .collect::>(); + let overlays = plan + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(); + match plan.base.compose(&overlays) { + Ok(deck) => { + let diff = plan.base.semantic_diff(&deck); + if json_output { + let changes = diff + .changes + .iter() + .map(|change| { + json!({ + "kind": semantic_kind_name(change.kind), + "path": change.path, + "before": change.before, + "after": change.after, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "package": plan.package.as_ref().map(package_json), + "target": plan.target, + "qualified_name": plan.qualified_name, + "owner": plan.owner.as_ref().map(|package| json!({ + "id": package.id, + "version": package.version, + })), + "target_expansion": target_expansion, + "base": plan.base_label, + "overlay_stack": overlay_stack, + "changes": changes, + "errors": [], + })) + .unwrap() + ); + } else { + println!("changes:"); + if diff.is_empty() { + println!(" none"); + } else { + for change in diff.changes { + println!(" {} {}", semantic_kind_name(change.kind), change.path); + if let Some(before) = change.before { + println!(" before: {}", one_line(&before)); + } + if let Some(after) = change.after { + println!(" after: {}", one_line(&after)); + } + } + } + } + Ok(()) + } + Err(report) => Err(output::compose_error( + "explain", + json!({ + "package": plan.package.as_ref().map(package_json), + "target": plan.target, + "qualified_name": plan.qualified_name, + "target_expansion": target_expansion, + "base": plan.base_label, + "overlay_stack": overlay_stack, + }), + &report, + )), + } +} diff --git a/crates/brain-brew-cli/src/commands/export.rs b/crates/brain-brew-cli/src/commands/export.rs new file mode 100644 index 0000000..b1e608e --- /dev/null +++ b/crates/brain-brew-cli/src/commands/export.rs @@ -0,0 +1,293 @@ +use std::path::Path; + +use brain_brew_core::CanonicalDeck; +use brain_brew_formats::crowdanki; + +use crate::args::{parse_manifest_target_export_args, parse_overlay_out_media}; +use crate::commands::verify; +use crate::help; +use crate::io::{configured_crowdanki_out, manifest_root, read_deck_and_overlays}; +use crate::media_assets::{collect_media_assets, validate_media_semantics, verify_owned_media}; +use crate::media_ownership::MediaRootSelections; +use crate::media_verification::MediaVerificationMode; +use crate::output; +use crate::output_transaction::{OutputArtifact, publish_output_tree}; +use crate::path_authorization::PathAuthorizer; +use crate::planner::{ManifestRegistry, TargetPlan}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if matches!(args, [flag] if flag == "--help" || flag == "-h") + || matches!(args, [format, flag] if format == "crowdanki" && (flag == "--help" || flag == "-h")) + { + print!("{}", help::command("export").expect("export help exists")); + return Ok(()); + } + if args.first().map(String::as_str) != Some("crowdanki") { + return Err(help::usage_error( + "export", + "usage: brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder", + )); + } + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let manifest_args = parse_manifest_target_export_args(&args[1..])?; + let registry = ManifestRegistry::load_with_policy( + &manifest_args.manifest_path, + &manifest_args.include_paths, + &manifest_args.package_roots, + &manifest_args.discovery_policy, + )?; + let plan = registry.plan(&manifest_args.target)?; + // Export destinations belong to the caller-selected workspace, never a + // dependency package/cache root selected while resolving the target. + let root = manifest_root(&manifest_args.manifest_path); + let out_dir = if let Some(out_path) = manifest_args.out_path.clone() { + out_path + } else if let Some(path) = configured_crowdanki_out(&plan.target_manifest, &plan.target) { + PathAuthorizer::new("workspace", &root)? + .authorize_create( + &manifest_args.manifest_path, + format!("targets.{}.exports.crowdanki.out", plan.target), + &path, + ) + .map_err(|error| error.to_string())? + .into_path_buf() + } else { + PathAuthorizer::new("workspace", &root)? + .authorize_create( + &manifest_args.manifest_path, + "generated CrowdAnki output", + &format!("build/crowdanki/{}", plan.target), + ) + .map_err(|error| error.to_string())? + .into_path_buf() + }; + let warnings = verify::stale_translation_warnings(&plan)?; + let deck = plan.compose().map_err(|report| { + output::compose_error( + "export", + serde_json::json!({ + "manifest": manifest_args.manifest_path, + "target": manifest_args.target, + }), + &report, + ) + })?; + if manifest_args.media_mode == MediaVerificationMode::ReferenceOnly + && !manifest_args.media_roots.is_empty() + { + return Err("--media-mode reference-only cannot be combined with --media-root because reference-only mode intentionally skips all media root and byte validation".to_owned()); + } + let media_roots = MediaRootSelections::parse(®istry, &manifest_args.media_roots, &root)?; + return write_owned_crowdanki_export( + &plan, + &deck, + &out_dir, + &media_roots, + ExportRunOptions { + media_mode: manifest_args.media_mode, + json_output: manifest_args.json_output, + warnings, + force: manifest_args.force, + }, + ); + } + + if args.len() < 4 { + return Err(help::usage_error( + "export", + "usage: brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder", + )); + } + let deck_path = Path::new(&args[1]); + let export_args = parse_overlay_out_media(&args[2..])?; + let Some(out_dir) = export_args.out_path else { + return Err("missing --out".to_owned()); + }; + + let (base, overlays) = read_deck_and_overlays(deck_path, &export_args.overlay_paths)?; + let warnings = verify::stale_translation_warnings_for_overlays("ad-hoc", &base, &overlays)?; + let deck = base + .compose( + &overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + .map_err(|report| { + output::compose_error( + "export", + serde_json::json!({ + "source": deck_path, + "overlays": export_args.overlay_paths, + }), + &report, + ) + })?; + write_crowdanki_export( + &deck, + &out_dir, + export_args.media_root.as_deref(), + export_args.media_mode, + export_args.json_output, + warnings, + export_args.force, + ) +} + +struct ExportRunOptions { + media_mode: MediaVerificationMode, + json_output: bool, + warnings: Vec, + force: bool, +} + +fn write_owned_crowdanki_export( + plan: &TargetPlan, + deck: &CanonicalDeck, + out_dir: &Path, + media_roots: &MediaRootSelections, + mut options: ExportRunOptions, +) -> Result<(), String> { + let verification = verify_owned_media(plan, deck, media_roots, options.media_mode, true)?; + options.warnings.extend(verification.warnings); + let export = crowdanki::export_deck(deck).map_err(|error| error.to_string())?; + let mut artifacts = vec![OutputArtifact::new( + "deck.json", + export.deck_json.as_bytes().to_vec(), + )]; + artifacts.extend( + verification + .assets + .into_iter() + .map(|(path, bytes)| OutputArtifact::new(path, bytes)), + ); + publish_output_tree(out_dir, artifacts, options.force)?; + print_export_result( + deck, + out_dir, + options.media_mode, + options.warnings, + options.json_output, + &export.omitted_tombstones, + ); + Ok(()) +} + +fn write_crowdanki_export( + deck: &CanonicalDeck, + out_dir: &Path, + media_root: Option<&Path>, + media_mode: MediaVerificationMode, + json_output: bool, + mut warnings: Vec, + force: bool, +) -> Result<(), String> { + if media_mode == MediaVerificationMode::ReferenceOnly && media_root.is_some() { + return Err("--media-mode reference-only cannot be combined with --media-root because reference-only mode intentionally skips all media root and byte validation".to_owned()); + } + warnings.extend(validate_media_semantics(deck, media_mode)?); + if let Some(warning) = media_mode.development_warning(active_media_count(deck)) { + warnings.push(warning); + } + let export = crowdanki::export_deck(deck).map_err(|error| error.to_string())?; + let mut artifacts = vec![OutputArtifact::new( + "deck.json", + export.deck_json.as_bytes().to_vec(), + )]; + match (media_mode, media_root) { + (MediaVerificationMode::Strict, Some(media_root)) => artifacts.extend( + collect_media_assets(deck, media_root)? + .into_iter() + .map(|(path, bytes)| OutputArtifact::new(path, bytes)), + ), + (MediaVerificationMode::Strict, None) if !deck.media.is_empty() => { + return Err("strict media verification requires --media-root for a deck with declared media; use --media-mode reference-only only for an explicitly non-release development export".to_owned()); + } + _ => {} + } + publish_output_tree(out_dir, artifacts, force)?; + print_export_result( + deck, + out_dir, + media_mode, + warnings, + json_output, + &export.omitted_tombstones, + ); + Ok(()) +} + +fn active_media_count(deck: &CanonicalDeck) -> usize { + deck.media + .keys() + .filter(|id| { + deck.tombstones + .blocking(&brain_brew_core::TombstoneAddress::MediaReference { + media_id: (*id).clone(), + }) + .is_none() + }) + .count() +} + +fn print_export_result( + deck: &CanonicalDeck, + out_dir: &Path, + media_mode: MediaVerificationMode, + warnings: Vec, + json_output: bool, + omitted_tombstones: &[brain_brew_core::TombstoneAddress], +) { + let media_count = active_media_count(deck); + let release_ready = media_mode.release_ready(media_count); + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "exported", + "format": "crowdanki", + "output": out_dir.join("deck.json").display().to_string(), + "media": { + "mode": media_mode.name(), + "declarations": media_count, + "release_ready": release_ready, + "assets_copied": if media_mode == MediaVerificationMode::Strict { media_count } else { 0 }, + }, + "warnings": warnings, + "omitted_tombstones": omitted_tombstones.iter().map(|address| serde_json::json!({ + "kind": address.kind(), + "path": address.to_string(), + })).collect::>(), + })) + .expect("export status JSON serializes") + ); + return; + } + for warning in &warnings { + eprintln!("warning: {warning}"); + } + let mut details = vec![("output", out_dir.join("deck.json").display().to_string())]; + details.extend(output::deck_stats(deck)); + details.push(( + "media verification", + if release_ready { + media_mode.name().to_owned() + } else { + "reference_only (NOT RELEASE-READY)".to_owned() + }, + )); + if !omitted_tombstones.is_empty() { + details.push(( + "omitted tombstones", + omitted_tombstones + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + )); + } + output::print_success("exported CrowdAnki deck", &details); +} diff --git a/crates/brain-brew-cli/src/commands/fmt.rs b/crates/brain-brew-cli/src/commands/fmt.rs new file mode 100644 index 0000000..c1b862f --- /dev/null +++ b/crates/brain-brew-cli/src/commands/fmt.rs @@ -0,0 +1,118 @@ +use std::fs; +use std::path::Path; + +use brain_brew_formats::{canonical_yaml, lockfile, manifest, media_map}; + +use crate::help; +use crate::io::{ + canonical_source_document, overlay_source_document, workspace_root_for_source_path, +}; +use crate::output; +use crate::workspace_mutation::{PlannedWorkspaceFile, commit_workspace_files, recover_workspace}; + +#[derive(Clone, Copy)] +enum SourceKind { + CanonicalDeck, + Overlay, + Manifest, + Lockfile, + MediaMap, +} + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("fmt").expect("fmt help exists")); + return Ok(()); + } + if args.len() != 1 { + return Err(help::usage_error("fmt", "usage: brainbrew fmt ")); + } + let path = Path::new(&args[0]); + let workspace_root = workspace_root_for_source_path(path); + recover_workspace(&workspace_root)?; + + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let migration_diagnostics = + canonical_yaml::overlay_migration_diagnostics(&input).unwrap_or_default(); + let (kind, formatted) = format_typed_source(path, &input)?; + if formatted != input { + let replacement = formatted.into_bytes(); + let original = input.into_bytes(); + let planned = PlannedWorkspaceFile::validated(path, original, replacement, |bytes| { + validate_typed_source(path, kind, bytes) + })?; + commit_workspace_files(&workspace_root, vec![planned])?; + } + output::print_success("formatted source", &[("path", path.display().to_string())]); + for diagnostic in migration_diagnostics { + eprintln!("warning: {}: {}", diagnostic.path, diagnostic.message); + } + Ok(()) +} + +fn format_typed_source(path: &Path, input: &str) -> Result<(SourceKind, String), String> { + let mut errors = Vec::new(); + match canonical_source_document(path, input).and_then(|document| { + document + .emit() + .map(|emission| emission.root().text().to_owned()) + .map_err(|error| error.to_string()) + }) { + Ok(formatted) => return Ok((SourceKind::CanonicalDeck, formatted)), + Err(error) => errors.push(format!("deck: {error}")), + } + match overlay_source_document(path, input).and_then(|document| { + document + .emit() + .map(|emission| emission.root().text().to_owned()) + .map_err(|error| error.to_string()) + }) { + Ok(formatted) => return Ok((SourceKind::Overlay, formatted)), + Err(error) => errors.push(format!("overlay: {error}")), + } + match manifest::format_str(input) { + Ok(formatted) => return Ok((SourceKind::Manifest, formatted)), + Err(error) => errors.push(format!("manifest: {error}")), + } + match lockfile::format_str(input) { + Ok(formatted) => return Ok((SourceKind::Lockfile, formatted)), + Err(error) => errors.push(format!("lockfile: {error}")), + } + match media_map::format_str(input) { + Ok(formatted) => return Ok((SourceKind::MediaMap, formatted)), + Err(error) => errors.push(format!("media map: {error}")), + } + Err(format!( + "{}: unrecognized Brain Brew source file ({})", + path.display(), + errors.join("; ") + )) +} + +fn validate_typed_source(path: &Path, kind: SourceKind, bytes: &[u8]) -> Result<(), String> { + let input = std::str::from_utf8(bytes) + .map_err(|error| format!("{}: replacement is not UTF-8: {error}", path.display()))?; + match kind { + SourceKind::CanonicalDeck => canonical_source_document(path, input).and_then(|document| { + document + .emit() + .map(|_| ()) + .map_err(|error| error.to_string()) + }), + SourceKind::Overlay => overlay_source_document(path, input).and_then(|document| { + document + .emit() + .map(|_| ()) + .map_err(|error| error.to_string()) + }), + SourceKind::Manifest => manifest::from_str(input) + .map(|_| ()) + .map_err(|e| e.to_string()), + SourceKind::Lockfile => lockfile::from_str(input) + .map(|_| ()) + .map_err(|e| e.to_string()), + SourceKind::MediaMap => media_map::from_str(input) + .map(|_| ()) + .map_err(|e| e.to_string()), + } +} diff --git a/crates/brain-brew-cli/src/commands/import.rs b/crates/brain-brew-cli/src/commands/import.rs new file mode 100644 index 0000000..0172b62 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/import.rs @@ -0,0 +1,465 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::canonical_source_document::CanonicalSourceDocument; +use brain_brew_formats::crowdanki::{self, CrowdAnkiImportPlan}; +use brain_brew_formats::source_document::{SourceFile, SourceProvenance}; +use serde_json::json; + +use crate::output_transaction::{OutputArtifact, publish_output_tree}; +use crate::path_authorization::PathAuthorizer; +use crate::workspace_mutation::write_output_file; + +const USAGE: &str = "usage:\n brainbrew import crowdanki plan --out import-plan.json [--media-root media/ | --media-mode reference-only] [--force] [--json]\n brainbrew import crowdanki review --plan import-plan.json [--json]\n brainbrew import crowdanki apply --plan import-plan.json --approve-plan --out workspace-dir [--media-root media/ | --media-mode reference-only] [--force] [--json]"; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.first().map(String::as_str) != Some("crowdanki") { + return Err(USAGE.to_owned()); + } + match args.get(1).map(String::as_str) { + Some("plan") => run_plan(&args[2..]), + Some("review") => run_review(&args[2..]), + Some("apply") => run_apply(&args[2..]), + Some(_) if args.iter().any(|arg| arg == "--accept-suggested-ids") => Err( + "--accept-suggested-ids is removed: generate and review a plan with `brainbrew import crowdanki plan`, then apply it with `--approve-plan`" + .to_owned(), + ), + _ => Err(USAGE.to_owned()), + } +} + +fn run_plan(args: &[String]) -> Result<(), String> { + let parsed = parse_plan_args(args)?; + let source_path = parsed.deck_dir.join("deck.json"); + let source = + fs::read(&source_path).map_err(|error| format!("{}: {error}", source_path.display()))?; + let supplied = read_import_media_bytes( + &source, + &source_path, + parsed.media_root.as_deref(), + parsed.reference_only, + )?; + let plan = if parsed.reference_only { + crowdanki::plan_import(&source) + } else { + crowdanki::plan_import_with_media(&source, &supplied) + } + .map_err(|error| format!("{}: {error}", source_path.display()))?; + let plan_bytes = if parsed + .out_path + .extension() + .and_then(|extension| extension.to_str()) + == Some("yaml") + || parsed + .out_path + .extension() + .and_then(|extension| extension.to_str()) + == Some("yml") + { + plan.to_canonical_yaml() + .map_err(|error| error.to_string())? + .into_bytes() + } else { + plan.to_canonical_json() + .map_err(|error| error.to_string())? + .into_bytes() + }; + write_output_file(&parsed.out_path, plan_bytes, parsed.force, |bytes| { + let reread = CrowdAnkiImportPlan::from_bytes(bytes).map_err(|error| error.to_string())?; + if reread == plan { + Ok(()) + } else { + Err("generated import plan is not canonical".to_owned()) + } + })?; + if parsed.json { + println!( + "{}", + serde_json::to_string(&json!({ + "schema_version": 1, + "action": "plan", + "plan": parsed.out_path, + "entries": plan.entries.len(), + "source_sha256": plan.provenance.source_sha256, + })) + .expect("JSON success serializes") + ); + } else { + println!( + "generated CrowdAnki import plan: {}", + parsed.out_path.display() + ); + print_review_summary(&plan); + } + Ok(()) +} + +fn run_review(args: &[String]) -> Result<(), String> { + let parsed = parse_review_args(args)?; + let bytes = fs::read(&parsed.plan_path) + .map_err(|error| format!("{}: {error}", parsed.plan_path.display()))?; + let plan = CrowdAnkiImportPlan::from_bytes(&bytes) + .map_err(|error| format!("{}: {error}", parsed.plan_path.display()))?; + if parsed.json { + println!( + "{}", + serde_json::to_string(&json!({ + "schema_version": 1, + "action": "review", + "plan": parsed.plan_path, + "format": plan.format, + "version": plan.version, + "entries": plan.entries, + })) + .expect("JSON success serializes") + ); + } else { + println!("CrowdAnki import plan: {} v{}", plan.format, plan.version); + println!( + "source sha256: {} ({} bytes)", + plan.provenance.source_sha256, plan.provenance.source_bytes + ); + print_review_summary(&plan); + } + Ok(()) +} + +fn run_apply(args: &[String]) -> Result<(), String> { + let parsed = parse_apply_args(args)?; + let deck_json_path = parsed.deck_dir.join("deck.json"); + let deck_json = fs::read(&deck_json_path) + .map_err(|error| format!("{}: {error}", deck_json_path.display()))?; + let plan_bytes = fs::read(&parsed.plan_path) + .map_err(|error| format!("{}: {error}", parsed.plan_path.display()))?; + let plan = CrowdAnkiImportPlan::from_bytes(&plan_bytes) + .map_err(|error| format!("{}: {error}", parsed.plan_path.display()))?; + let supplied = read_import_media_bytes( + &deck_json, + &deck_json_path, + parsed.media_root.as_deref(), + parsed.reference_only, + )?; + let deck = if parsed.reference_only { + crowdanki::apply_import_plan(&deck_json, &plan, parsed.approve_plan) + } else { + crowdanki::apply_import_plan_with_media(&deck_json, &plan, parsed.approve_plan, &supplied) + } + .map_err(|error| format!("{}: {error}", deck_json_path.display()))?; + let provenance = SourceProvenance::new(parsed.out_path.join("deck.yaml").display().to_string()) + .with_source_root(parsed.out_path.display().to_string()); + let emission = CanonicalSourceDocument::from_deck(provenance, deck) + .and_then(|document| document.emit()) + .map_err(|error| error.to_string())?; + if !emission.included().is_empty() { + return Err("CrowdAnki import unexpectedly planned included source outputs".to_owned()); + } + let source_bytes = emission.root().text().as_bytes().to_vec(); + // Keep the pre-v2 source-file destination only for explicit reference-only imports. + // Strict imports always use the clean source-plus-media workspace transaction below. + if parsed.reference_only && parsed.out_path.extension().is_some() { + write_output_file( + &parsed.out_path, + source_bytes, + parsed.force, + canonical_import_validator(&parsed.out_path), + )?; + } else { + let mut artifacts = vec![OutputArtifact::new("deck.yaml", source_bytes)]; + if !parsed.reference_only { + artifacts.extend(supplied.into_iter().map(|asset| { + OutputArtifact::new(PathBuf::from("media").join(asset.path), asset.bytes) + })); + } + publish_output_tree(&parsed.out_path, artifacts, parsed.force)?; + } + if parsed.json { + println!( + "{}", + serde_json::to_string(&json!({ + "schema_version": 1, + "action": "apply", + "plan": parsed.plan_path, + "out": parsed.out_path, + "media_mode": if parsed.reference_only { "reference_only" } else { "strict" }, + "status": "imported", + })) + .expect("JSON success serializes") + ); + } else { + println!("applied reviewed CrowdAnki import plan"); + } + Ok(()) +} + +fn print_review_summary(plan: &CrowdAnkiImportPlan) { + let automatic = plan + .entries + .iter() + .filter(|entry| { + matches!( + entry.status, + crowdanki::CrowdAnkiImportPlanStatus::Automatic + ) + }) + .count(); + let unresolved = plan + .entries + .iter() + .filter(|entry| { + matches!( + entry.status, + crowdanki::CrowdAnkiImportPlanStatus::RequiresOverride + ) + }) + .count(); + println!(" automatic suggestions: {automatic}"); + println!(" unresolved collisions: {unresolved}"); + for entry in &plan.entries { + let decision = match &entry.decision { + crowdanki::CrowdAnkiImportPlanDecision::Automatic => "automatic".to_owned(), + crowdanki::CrowdAnkiImportPlanDecision::Override { stable_id } => { + format!("override {stable_id}") + } + crowdanki::CrowdAnkiImportPlanDecision::Reject => "rejected".to_owned(), + }; + println!( + " {}: {} -> {} [{}]", + entry.source_path, + entry.kind.name(), + entry.suggested_id, + decision + ); + } +} + +struct PlanArgs { + deck_dir: PathBuf, + out_path: PathBuf, + media_root: Option, + reference_only: bool, + force: bool, + json: bool, +} +struct ReviewArgs { + plan_path: PathBuf, + json: bool, +} +struct ApplyArgs { + deck_dir: PathBuf, + plan_path: PathBuf, + out_path: PathBuf, + media_root: Option, + reference_only: bool, + force: bool, + approve_plan: bool, + json: bool, +} + +struct ImportFlags { + out_path: Option, + media_root: Option, + reference_only: bool, + force: bool, + plan_path: Option, + approve_plan: bool, + json: bool, +} + +fn parse_plan_args(args: &[String]) -> Result { + let (deck_dir, flags) = positional_and_flags(args)?; + let flags = parse_import_flags(&flags)?; + if flags.approve_plan || flags.plan_path.is_some() { + return Err("plan generation does not accept --plan or --approve-plan".to_owned()); + } + Ok(PlanArgs { + deck_dir, + out_path: flags.out_path.ok_or_else(|| "missing --out".to_owned())?, + media_root: flags.media_root, + reference_only: flags.reference_only, + force: flags.force, + json: flags.json, + }) +} +fn parse_review_args(args: &[String]) -> Result { + let (_positionals, flags) = split_positionals(args); + if !_positionals.is_empty() { + return Err(USAGE.to_owned()); + } + let flags = parse_import_flags(&flags)?; + if flags.force || flags.approve_plan || flags.media_root.is_some() || flags.reference_only { + return Err("review accepts only --plan and --json".to_owned()); + } + Ok(ReviewArgs { + plan_path: flags.plan_path.ok_or_else(|| "missing --plan".to_owned())?, + json: flags.json, + }) +} +fn parse_apply_args(args: &[String]) -> Result { + let (deck_dir, flags) = positional_and_flags(args)?; + let flags = parse_import_flags(&flags)?; + Ok(ApplyArgs { + deck_dir, + plan_path: flags.plan_path.ok_or_else(|| "missing --plan".to_owned())?, + out_path: flags.out_path.ok_or_else(|| "missing --out".to_owned())?, + media_root: flags.media_root, + reference_only: flags.reference_only, + force: flags.force, + approve_plan: flags.approve_plan, + json: flags.json, + }) +} + +fn positional_and_flags(args: &[String]) -> Result<(PathBuf, Vec), String> { + let (positionals, flags) = split_positionals(args); + if positionals.len() != 1 { + return Err(USAGE.to_owned()); + } + Ok((PathBuf::from(&positionals[0]), flags)) +} +fn split_positionals(args: &[String]) -> (Vec, Vec) { + let mut positionals = Vec::new(); + let mut flags = Vec::new(); + let mut index = 0; + while index < args.len() { + if args[index].starts_with('-') { + flags.push(args[index].clone()); + if matches!( + args[index].as_str(), + "--out" | "--plan" | "--media-root" | "--media-mode" + ) && index + 1 < args.len() + { + index += 1; + flags.push(args[index].clone()); + } + } else { + positionals.push(args[index].clone()); + } + index += 1; + } + (positionals, flags) +} +fn parse_import_flags(args: &[String]) -> Result { + let mut out = None; + let mut media_root = None; + let mut reference_only = false; + let mut force = false; + let mut plan = None; + let mut approve = false; + let mut json = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--out" if out.is_none() => { + let value = args + .get(index + 1) + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| "--out requires a path".to_owned())?; + out = Some(PathBuf::from(value)); + index += 2; + } + "--plan" if plan.is_none() => { + let value = args + .get(index + 1) + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| "--plan requires a path".to_owned())?; + plan = Some(PathBuf::from(value)); + index += 2; + } + "--media-root" if media_root.is_none() => { + let value = args + .get(index + 1) + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| "--media-root requires a directory".to_owned())?; + media_root = Some(PathBuf::from(value)); + index += 2; + } + "--media-mode" if !reference_only => { + let value = args + .get(index + 1) + .ok_or_else(|| "--media-mode requires reference-only".to_owned())?; + if value != "reference-only" { + return Err("import supports only --media-mode reference-only".to_owned()); + } + reference_only = true; + index += 2; + } + "--force" if !force => { + force = true; + index += 1; + } + "--approve-plan" if !approve => { + approve = true; + index += 1; + } + "--json" if !json => { + json = true; + index += 1; + } + "--out" | "--plan" | "--media-root" | "--media-mode" | "--force" | "--approve-plan" + | "--json" => { + return Err(format!("duplicate import argument {:?}", args[index])); + } + other => return Err(format!("unexpected import argument {other:?}")), + } + } + if media_root.is_some() && reference_only { + return Err("--media-root cannot be combined with --media-mode reference-only".to_owned()); + } + Ok(ImportFlags { + out_path: out, + media_root, + reference_only, + force, + plan_path: plan, + approve_plan: approve, + json, + }) +} + +fn canonical_import_validator(path: &Path) -> impl FnOnce(&[u8]) -> Result<(), String> + '_ { + move |bytes| { + let text = std::str::from_utf8(bytes).map_err(|error| error.to_string())?; + let source = SourceFile::new(SourceProvenance::new(path.display().to_string()), text); + let document = CanonicalSourceDocument::parse(source).map_err(|error| error.to_string())?; + let emitted = document.emit().map_err(|error| error.to_string())?; + if emitted.root().text() == text { + Ok(()) + } else { + Err("generated import output is not canonical Canonical Deck source".to_owned()) + } + } +} + +fn read_import_media_bytes( + deck_json: &[u8], + source_path: &Path, + media_root: Option<&Path>, + reference_only: bool, +) -> Result, String> { + let references = crowdanki::import_media_references(deck_json) + .map_err(|error| format!("{}: {error}", source_path.display()))?; + if references.is_empty() || reference_only { + return Ok(Vec::new()); + } + let root = media_root.ok_or_else(|| { + format!( + "CrowdAnki import has {} declared media paths; pass --media-root to import verified bytes or --media-mode reference-only for explicit non-release source-only output", + references.len() + ) + })?; + let authorizer = PathAuthorizer::new("CrowdAnki import media", root)?; + references + .into_iter() + .map(|reference| { + let path = authorizer + .authorize_read(source_path, &reference.source_path, &reference.path) + .map_err(|error| error.to_string())? + .into_path_buf(); + let bytes = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(crowdanki::CrowdAnkiImportMediaBytes { + path: reference.path, + bytes, + }) + }) + .collect() +} diff --git a/crates/brain-brew-cli/src/commands/lock.rs b/crates/brain-brew-cli/src/commands/lock.rs new file mode 100644 index 0000000..c5c8bcb --- /dev/null +++ b/crates/brain-brew-cli/src/commands/lock.rs @@ -0,0 +1,1288 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use brain_brew_formats::lockfile::{ + self, FederationLock, LOCKFILE_VERSION, LockedPackage, LockedPackageMetadata, LockedSource, + OriginalSource, +}; +use flate2::read::GzDecoder; +use fs2::FileExt as _; +use nix_nar::Encoder; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tempfile::{NamedTempFile, TempDir}; + +use crate::fetch_policy::{self, FetchPolicy, budget_error}; +use crate::help; +use crate::io::read_manifest; +use crate::output; +use crate::package_tree; +use crate::path_authorization::PathAuthorizer; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("lock").expect("lock help exists")); + return Ok(()); + } + let Some(subcommand) = args.first().map(String::as_str) else { + return Err(help::usage_error( + "lock", + "usage: brainbrew lock ", + )); + }; + match subcommand { + "update" => update(&args[1..]), + "verify" => verify(&args[1..]), + other => Err(format!("unknown lock subcommand {other:?}")), + } +} + +fn update(args: &[String]) -> Result<(), String> { + let args = parse_lock_update_args(args)?; + // Existing locks must pass the current schema before any source is fetched + // or package manifest is loaded. This also makes v1 migration fail early. + let mut lock = read_lock_or_empty(&args.lock_path)?; + let fetch_requested = args.source.to_fetch_source()?; + let fetched = fetch_source(&fetch_requested, None, &FetchPolicy::default())?; + let requested = args.source.to_requested_source(&args.lock_path)?; + let package_manifest_raw = args + .package_manifest + .to_str() + .ok_or_else(|| "--package-manifest must be valid UTF-8".to_owned())?; + let package_manifest = PathAuthorizer::new("fetched package", &fetched.source_path)? + .authorize_read( + &args.lock_path, + "packages..manifest", + package_manifest_raw, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + let manifest = read_manifest(&package_manifest)?; + let package = manifest.package.as_ref().ok_or_else(|| { + format!( + "locked package source {} has no package metadata in {}", + fetched.source_path.display(), + args.package_manifest.display() + ) + })?; + if package.id != args.package_id { + return Err(format!( + "locked package source declares package id {}, expected {}", + package.id, args.package_id + )); + } + + lock.packages.insert( + args.package_id.clone(), + LockedPackage { + manifest: args.package_manifest.display().to_string(), + package: LockedPackageMetadata { + version: package.version.clone(), + }, + original: requested.original_source(), + locked: requested.locked_source(&fetched)?, + }, + ); + let formatted = lockfile::to_string(&lock).map_err(|error| error.to_string())?; + if let Some(parent) = args.lock_path.parent() { + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + fs::write(&args.lock_path, formatted) + .map_err(|error| format!("{}: {error}", args.lock_path.display()))?; + + output::print_success( + format!("updated lock package {}", args.package_id), + &[ + ("lock", args.lock_path.display().to_string()), + ("version", package.version.clone()), + ("nar_hash", fetched.nar_hash), + ], + ); + Ok(()) +} + +fn verify(args: &[String]) -> Result<(), String> { + let args = parse_lock_verify_args(args)?; + let lock = read_lock(&args.lock_path)?; + for (package_id, package) in &lock.packages { + let fetched = fetch_locked_source_for_verify(&args.lock_path, package_id, &package.locked)?; + let expected_hash = package.locked.nar_hash(); + if fetched.nar_hash != expected_hash { + return Err(format!( + "locked package {package_id} nar_hash mismatch: expected {expected_hash}, found {}", + fetched.nar_hash + )); + } + let manifest_path = PathAuthorizer::new("fetched package", &fetched.source_path)? + .authorize_read( + &args.lock_path, + format!("packages.{package_id}.manifest"), + &package.manifest, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + verify_locked_manifest_metadata(package_id, package, &manifest_path)?; + } + + let suffix = if lock.packages.len() == 1 { "" } else { "s" }; + output::print_success( + format!("verified {} locked package{suffix}", lock.packages.len()), + &[("lock", args.lock_path.display().to_string())], + ); + Ok(()) +} + +#[derive(Debug)] +struct LockUpdateArgs { + lock_path: PathBuf, + package_id: String, + package_manifest: PathBuf, + source: UpdateSource, +} + +#[derive(Debug)] +enum UpdateSource { + Path(PathBuf), + Git { + url: String, + reference: Option, + rev: Option, + }, + Tarball { + url: String, + }, +} + +impl UpdateSource { + fn to_fetch_source(&self) -> Result { + match self { + Self::Path(path) => { + let path = if path.is_absolute() { + path.clone() + } else { + env::current_dir() + .map_err(|error| format!("cannot resolve current directory: {error}"))? + .join(path) + }; + Ok(RequestedSource::Path { + path: path.display().to_string(), + }) + } + _ => self.to_requested_source(Path::new(".")), + } + } + + fn to_requested_source(&self, lock_path: &Path) -> Result { + match self { + Self::Path(path) => { + let path = path_for_lock(path, lock_path)?; + Ok(RequestedSource::Path { + path: path.display().to_string(), + }) + } + Self::Git { + url, + reference, + rev, + } => Ok(RequestedSource::Git { + url: normalize_github_url(url), + reference: reference.clone(), + rev: rev.clone(), + }), + Self::Tarball { url } => Ok(RequestedSource::Tarball { url: url.clone() }), + } + } +} + +#[derive(Debug)] +struct LockVerifyArgs { + lock_path: PathBuf, +} + +fn parse_lock_update_args(args: &[String]) -> Result { + let mut lock_path = PathBuf::from("brainbrew.lock"); + let mut package_id = None; + let mut package_manifest = PathBuf::from("brainbrew.yaml"); + let mut path = None; + let mut git = None; + let mut tarball = None; + let mut reference = None; + let mut rev = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--lock" => { + let Some(value) = args.get(index + 1) else { + return Err("--lock requires a path".to_owned()); + }; + lock_path = PathBuf::from(value); + index += 2; + } + "--package" => { + let Some(value) = args.get(index + 1) else { + return Err("--package requires a package id".to_owned()); + }; + package_id = Some(value.clone()); + index += 2; + } + "--package-manifest" => { + let Some(value) = args.get(index + 1) else { + return Err("--package-manifest requires a path".to_owned()); + }; + package_manifest = PathBuf::from(value); + index += 2; + } + "--path" => { + let Some(value) = args.get(index + 1) else { + return Err("--path requires a directory".to_owned()); + }; + path = Some(PathBuf::from(value)); + index += 2; + } + "--git" => { + let Some(value) = args.get(index + 1) else { + return Err("--git requires a URL".to_owned()); + }; + git = Some(value.clone()); + index += 2; + } + "--tarball" => { + let Some(value) = args.get(index + 1) else { + return Err("--tarball requires a URL".to_owned()); + }; + tarball = Some(value.clone()); + index += 2; + } + "--ref" => { + let Some(value) = args.get(index + 1) else { + return Err("--ref requires a ref".to_owned()); + }; + reference = Some(value.clone()); + index += 2; + } + "--rev" => { + let Some(value) = args.get(index + 1) else { + return Err("--rev requires a revision".to_owned()); + }; + rev = Some(value.clone()); + index += 2; + } + other => return Err(format!("unexpected lock update argument {other:?}")), + } + } + let Some(package_id) = package_id else { + return Err("lock update requires --package".to_owned()); + }; + let source_count = + usize::from(path.is_some()) + usize::from(git.is_some()) + usize::from(tarball.is_some()); + if source_count != 1 { + return Err("lock update requires exactly one of --path, --git, or --tarball".to_owned()); + } + let source = if let Some(path) = path { + if reference.is_some() || rev.is_some() { + return Err("--ref and --rev are only valid with --git".to_owned()); + } + UpdateSource::Path(path) + } else if let Some(url) = git { + if reference.is_some() && rev.is_some() { + return Err("--ref and --rev cannot be used together".to_owned()); + } + UpdateSource::Git { + url, + reference, + rev, + } + } else { + if reference.is_some() || rev.is_some() { + return Err("--ref and --rev are only valid with --git".to_owned()); + } + UpdateSource::Tarball { + url: tarball.expect("source_count checked"), + } + }; + + Ok(LockUpdateArgs { + lock_path, + package_id, + package_manifest, + source, + }) +} + +fn parse_lock_verify_args(args: &[String]) -> Result { + let mut lock_path = PathBuf::from("brainbrew.lock"); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--lock" => { + let Some(value) = args.get(index + 1) else { + return Err("--lock requires a path".to_owned()); + }; + lock_path = PathBuf::from(value); + index += 2; + } + other => return Err(format!("unexpected lock verify argument {other:?}")), + } + } + Ok(LockVerifyArgs { lock_path }) +} + +fn read_lock(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + lockfile::from_str(&input).map_err(|error| format!("{}: {error}", path.display())) +} + +fn read_lock_or_empty(path: &Path) -> Result { + if path.exists() { + read_lock(path) + } else { + Ok(FederationLock { + version: LOCKFILE_VERSION, + packages: BTreeMap::new(), + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum RequestedSource { + Path { + path: String, + }, + Git { + url: String, + reference: Option, + rev: Option, + }, + Tarball { + url: String, + }, +} + +impl RequestedSource { + fn original_source(&self) -> OriginalSource { + match self { + Self::Path { path } => OriginalSource::Path { path: path.clone() }, + Self::Git { + url, + reference, + rev, + } => OriginalSource::Git { + url: url.clone(), + reference: reference.clone(), + rev: rev.clone(), + }, + Self::Tarball { url } => OriginalSource::Tarball { url: url.clone() }, + } + } + + fn locked_source(&self, fetched: &FetchedSource) -> Result { + match self { + Self::Path { path } => Ok(LockedSource::Path { + path: path.clone(), + nar_hash: fetched.nar_hash.clone(), + }), + Self::Git { url, .. } => { + let rev = fetched.rev.clone().ok_or_else(|| { + "GitHub source did not resolve to an immutable commit revision".to_owned() + })?; + Ok(LockedSource::Git { + url: url.clone(), + rev, + nar_hash: fetched.nar_hash.clone(), + }) + } + Self::Tarball { url } => Ok(LockedSource::Tarball { + url: url.clone(), + nar_hash: fetched.nar_hash.clone(), + }), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct FetchedSource { + pub(crate) source_path: PathBuf, + pub(crate) nar_hash: String, + pub(crate) rev: Option, +} + +pub(crate) fn fetch_locked_source( + lock_path: &Path, + package_id: &str, + source: &LockedSource, +) -> Result { + fetch_locked_source_with_mode(lock_path, package_id, source, FetchLockedMode::UseCache) +} + +fn fetch_locked_source_for_verify( + lock_path: &Path, + package_id: &str, + source: &LockedSource, +) -> Result { + fetch_locked_source_with_mode( + lock_path, + package_id, + source, + FetchLockedMode::VerifyLivePath, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FetchLockedMode { + UseCache, + VerifyLivePath, +} + +fn fetch_locked_source_with_mode( + lock_path: &Path, + package_id: &str, + source: &LockedSource, + mode: FetchLockedMode, +) -> Result { + let expected_hash = source.nar_hash(); + let verify_live_path = + mode == FetchLockedMode::VerifyLivePath && matches!(source, LockedSource::Path { .. }); + if !verify_live_path && let Some(cached) = cached_source(Some(expected_hash))? { + return Ok(cached); + } + + let requested = match source { + LockedSource::Path { path, .. } => RequestedSource::Path { + path: lock_relative_path(lock_path, path).display().to_string(), + }, + LockedSource::Git { url, rev, .. } => RequestedSource::Git { + url: url.clone(), + reference: None, + rev: Some(rev.clone()), + }, + LockedSource::Tarball { url, .. } => RequestedSource::Tarball { url: url.clone() }, + }; + fetch_source(&requested, Some(expected_hash), &FetchPolicy::default()) + .map_err(|error| format!("locked package {package_id}: {error}")) +} + +pub(crate) fn locked_package_manifest_paths(lock_path: &Path) -> Result, String> { + if !lock_path.exists() { + return Ok(Vec::new()); + } + let lock = read_lock(lock_path)?; + lock.packages + .iter() + .map(|(package_id, package)| { + let fetched = fetch_locked_source(lock_path, package_id, &package.locked)?; + let expected_hash = package.locked.nar_hash(); + if fetched.nar_hash != expected_hash { + return Err(format!( + "locked package {package_id} nar_hash mismatch: expected {expected_hash}, found {}", + fetched.nar_hash + )); + } + let manifest_path = PathAuthorizer::new("fetched package", &fetched.source_path)? + .authorize_read( + lock_path, + format!("packages.{package_id}.manifest"), + &package.manifest, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + verify_locked_manifest_metadata(package_id, package, &manifest_path)?; + Ok(manifest_path) + }) + .collect() +} + +fn fetch_source( + source: &RequestedSource, + expected_hash: Option<&str>, + policy: &FetchPolicy, +) -> Result { + match source { + RequestedSource::Path { path } => { + snapshot_source_tree(Path::new(path), expected_hash, None) + } + RequestedSource::Git { .. } => fetch_git_source(source, expected_hash, policy), + RequestedSource::Tarball { url } => fetch_tarball_source(url, expected_hash, None, policy), + } +} + +fn fetch_git_source( + source: &RequestedSource, + expected_hash: Option<&str>, + policy: &FetchPolicy, +) -> Result { + let RequestedSource::Git { + url, + reference, + rev, + } = source + else { + return Err("internal error: expected git source".to_owned()); + }; + let Some(repo) = GithubRepo::parse(url) else { + return Err(format!( + "native git locking currently supports GitHub HTTPS URLs; use --tarball for {url:?}" + )); + }; + let rev = match rev { + Some(rev) if lockfile::is_full_git_commit(rev) => rev.clone(), + Some(rev) => resolve_github_rev(&repo, Some(rev), policy)?, + None => resolve_github_rev(&repo, reference.as_deref(), policy)?, + }; + let tarball = repo.codeload_tarball_url(&rev); + fetch_tarball_source(&tarball, expected_hash, Some(rev), policy) +} + +fn fetch_tarball_source( + url: &str, + expected_hash: Option<&str>, + rev: Option, + policy: &FetchPolicy, +) -> Result { + if let Some(cached) = cached_source(expected_hash)? { + return Ok(FetchedSource { rev, ..cached }); + } + + let download = fetch_policy::fetch_to_temp( + url, + policy, + policy.max_download_bytes, + Some("application/octet-stream"), + )?; + let extracted = TempDir::new().map_err(|error| error.to_string())?; + let extracted_tree = extracted.path().join("archive"); + unpack_tarball(&download, &extracted_tree, policy, url)?; + let source_root = normalized_extracted_root(&extracted_tree)?; + snapshot_source_tree(&source_root, expected_hash, rev) +} + +fn snapshot_source_tree( + source_path: &Path, + expected_hash: Option<&str>, + rev: Option, +) -> Result { + package_tree::validate(source_path, "selected source package tree")?; + + let staging = TempDir::new().map_err(|error| error.to_string())?; + let staged_source = staging.path().join("source"); + package_tree::copy_filtered(source_path, &staged_source)?; + let nar_hash = validated_nar_hash(&staged_source, "staged package tree")?; + + if let Some(expected_hash) = expected_hash + && nar_hash != expected_hash + { + return Err(format!( + "nar_hash mismatch: expected {expected_hash}, found {nar_hash}" + )); + } + + let cache_path = publish_cache_tree(&staged_source, &nar_hash)?; + Ok(FetchedSource { + source_path: cache_path, + nar_hash, + rev, + }) +} + +fn cached_source(expected_hash: Option<&str>) -> Result, String> { + let Some(expected_hash) = expected_hash else { + return Ok(None); + }; + let path = cache_source_path(expected_hash); + if !path.exists() { + return Ok(None); + } + let actual_hash = validated_nar_hash(&path, "cached package tree")?; + if actual_hash != expected_hash { + return Err(format!( + "cached source {} nar_hash mismatch: expected {expected_hash}, found {actual_hash}; remove the tampered cache entry before retrying", + path.display() + )); + } + Ok(Some(FetchedSource { + source_path: path, + nar_hash: actual_hash, + rev: None, + })) +} + +fn read_json_url(url: &str, policy: &FetchPolicy) -> Result { + let download = fetch_policy::fetch_to_temp( + url, + policy, + policy.max_json_bytes, + Some("application/vnd.github+json"), + )?; + let body = fs::read(download.path()) + .map_err(|error| format!("package source {url:?}: failed to read staged JSON: {error}"))?; + serde_json::from_slice(&body).map_err(|error| format!("failed to parse {url} as JSON: {error}")) +} + +fn unpack_tarball( + download: &fetch_policy::DownloadedFile, + destination: &Path, + policy: &FetchPolicy, + source: &str, +) -> Result<(), String> { + let mut input = fs::File::open(download.path()) + .map_err(|error| format!("package source {source:?}: {error}"))?; + let mut magic = [0_u8; 2]; + let magic_len = input + .read(&mut magic) + .map_err(|error| format!("package source {source:?}: {error}"))?; + input + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("package source {source:?}: {error}"))?; + + let mut staged_tar = + NamedTempFile::new().map_err(|error| format!("package source {source:?}: {error}"))?; + if magic_len == 2 && magic == [0x1f, 0x8b] { + copy_decompressed_tar( + GzDecoder::new(input), + &mut staged_tar, + download, + policy, + source, + )?; + } else { + copy_decompressed_tar(input, &mut staged_tar, download, policy, source)?; + } + staged_tar.as_file_mut().sync_all().map_err(|error| { + format!("package source {source:?}: failed to sync staged tar: {error}") + })?; + package_tree::extract_tar( + staged_tar.path(), + destination, + policy, + source, + download.started, + ) + .map_err(|error| format!("failed to extract package archive: {error}")) +} + +fn copy_decompressed_tar( + mut input: impl Read, + output: &mut impl Write, + download: &fetch_policy::DownloadedFile, + policy: &FetchPolicy, + source: &str, +) -> Result<(), String> { + let ratio_limit = download.bytes.saturating_mul(policy.max_expansion_ratio); + let mut total = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + fetch_policy::check_total_deadline(source, policy, download.started)?; + let count = input.read(&mut buffer).map_err(|error| { + format!("package source {source:?}: truncated or invalid compressed archive: {error}") + })?; + if count == 0 { + break; + } + total = total.saturating_add(count as u64); + if total > policy.max_decompressed_tar_bytes { + return Err(budget_error( + source, + "decompressed_tar_bytes", + total, + policy.max_decompressed_tar_bytes, + )); + } + if total > ratio_limit { + let current_ratio = total + .saturating_add(download.bytes.saturating_sub(1)) + .checked_div(download.bytes) + .unwrap_or(u64::MAX); + return Err(budget_error( + source, + "archive_expansion_ratio", + current_ratio, + policy.max_expansion_ratio, + )); + } + output.write_all(&buffer[..count]).map_err(|error| { + format!("package source {source:?}: staged tar write failed: {error}") + })?; + } + Ok(()) +} + +fn normalized_extracted_root(path: &Path) -> Result { + let entries = fs::read_dir(path) + .map_err(|error| format!("{}: {error}", path.display()))? + .collect::, _>>() + .map_err(|error| error.to_string())?; + if entries.len() == 1 { + let only = entries[0].path(); + if only.is_dir() { + return Ok(only); + } + } + Ok(path.to_path_buf()) +} + +#[derive(Debug)] +struct GithubRepo { + owner: String, + name: String, +} + +impl GithubRepo { + fn parse(url: &str) -> Option { + let path = url.strip_prefix("https://github.com/")?; + let mut parts = path.trim_end_matches('/').split('/'); + let owner = parts.next()?.to_owned(); + let name = parts.next()?.trim_end_matches(".git").to_owned(); + if owner.is_empty() || name.is_empty() || parts.next().is_some() { + return None; + } + Some(Self { owner, name }) + } + + fn canonical_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.name) + } + + fn api_url(&self) -> String { + format!("https://api.github.com/repos/{}/{}", self.owner, self.name) + } + + fn commit_api_url(&self, reference: &str) -> String { + format!( + "https://api.github.com/repos/{}/{}/commits/{}", + self.owner, + self.name, + percent_encode_path_segment(reference) + ) + } + + fn codeload_tarball_url(&self, rev: &str) -> String { + format!( + "https://codeload.github.com/{}/{}/tar.gz/{}", + self.owner, self.name, rev + ) + } +} + +fn normalize_github_url(url: &str) -> String { + GithubRepo::parse(url) + .map(|repo| repo.canonical_url()) + .unwrap_or_else(|| url.to_owned()) +} + +fn resolve_github_rev( + repo: &GithubRepo, + reference: Option<&str>, + policy: &FetchPolicy, +) -> Result { + let reference = if let Some(reference) = reference { + reference.to_owned() + } else { + read_json_url(&repo.api_url(), policy)? + .get("default_branch") + .and_then(Value::as_str) + .ok_or_else(|| "GitHub repository response did not include default_branch".to_owned())? + .to_owned() + }; + read_json_url(&repo.commit_api_url(&reference), policy)? + .get("sha") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("GitHub commit response did not include sha for {reference:?}")) +} + +fn percent_encode_path_segment(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn verify_locked_manifest_metadata( + package_id: &str, + package: &LockedPackage, + manifest_path: &Path, +) -> Result<(), String> { + let manifest = read_manifest(manifest_path)?; + let metadata = manifest.package.as_ref().ok_or_else(|| { + format!( + "locked package {package_id} manifest {} has no package metadata", + manifest_path.display() + ) + })?; + if metadata.id != *package_id { + return Err(format!( + "locked package {package_id} manifest {} declares package id {}", + manifest_path.display(), + metadata.id + )); + } + if metadata.version != package.package.version { + return Err(format!( + "locked package {package_id} version mismatch: lock has {}, manifest has {}", + package.package.version, metadata.version + )); + } + Ok(()) +} + +pub(crate) fn validated_nar_hash(path: &Path, tree_name: &str) -> Result { + package_tree::validate(path, tree_name)?; + let hash = nar_hash_path(path)?; + package_tree::validate(path, tree_name)?; + Ok(hash) +} + +fn nar_hash_path(path: &Path) -> Result { + let mut encoder = Encoder::new(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = encoder + .read(&mut buffer) + .map_err(|error| format!("failed to encode {} as NAR: {error}", path.display()))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!( + "sha256-{}", + BASE64_STANDARD.encode(hasher.finalize()) + )) +} + +fn publish_cache_tree(staged_source: &Path, nar_hash: &str) -> Result { + let cache_path = cache_source_path(nar_hash); + let parent = cache_path + .parent() + .ok_or_else(|| format!("cache path {} has no parent", cache_path.display()))?; + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + + let lock_path = parent + .parent() + .unwrap_or(parent) + .join(".sources-publish.lock"); + let publication_lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .map_err(|error| format!("{}: {error}", lock_path.display()))?; + publication_lock + .lock_exclusive() + .map_err(|error| format!("failed to lock {}: {error}", lock_path.display()))?; + + let result = (|| { + if cache_path.exists() { + let cached_hash = validated_nar_hash(&cache_path, "cached package tree")?; + if cached_hash != nar_hash { + return Err(format!( + "cached source {} nar_hash mismatch: expected {nar_hash}, found {cached_hash}; remove the tampered cache entry before retrying", + cache_path.display() + )); + } + return Ok(cache_path.clone()); + } + + let publication = tempfile::Builder::new() + .prefix(".publish-") + .tempdir_in(parent) + .map_err(|error| format!("failed to create private cache publication tree: {error}"))?; + let candidate = publication.path().join("source"); + package_tree::copy_complete(staged_source, &candidate)?; + let candidate_hash = validated_nar_hash(&candidate, "publication package tree")?; + if candidate_hash != nar_hash { + return Err(format!( + "publication package tree hash changed: expected {nar_hash}, found {candidate_hash}" + )); + } + fs::rename(&candidate, &cache_path).map_err(|error| { + format!( + "failed to atomically publish {} to {}: {error}", + candidate.display(), + cache_path.display() + ) + })?; + + match validated_nar_hash(&cache_path, "cached package tree") { + Ok(published_hash) if published_hash == nar_hash => { + sync_directory(parent)?; + Ok(cache_path.clone()) + } + Ok(published_hash) => { + let _ = fs::remove_dir_all(&cache_path); + Err(format!( + "published cache {} nar_hash mismatch: expected {nar_hash}, found {published_hash}; incomplete publication was removed", + cache_path.display() + )) + } + Err(error) => { + let _ = fs::remove_dir_all(&cache_path); + Err(format!( + "published cache {} failed validation: {error}; incomplete publication was removed", + cache_path.display() + )) + } + } + })(); + let unlock_result = fs2::FileExt::unlock(&publication_lock) + .map_err(|error| format!("failed to unlock {}: {error}", lock_path.display())); + match (result, unlock_result) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(path), Ok(())) => Ok(path), + } +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), String> { + fs::File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("failed to sync cache directory {}: {error}", path.display())) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +fn path_for_lock(path: &Path, lock_path: &Path) -> Result { + let canonical_path = canonicalize_for_lock(path)?; + let lock_parent = lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_lock_parent = canonicalize_for_lock(lock_parent)?; + Ok(relative_path_between(&canonical_lock_parent, &canonical_path).unwrap_or(canonical_path)) +} + +fn canonicalize_for_lock(path: &Path) -> Result { + path.canonicalize() + .map_err(|error| format!("{}: {error}", path.display())) +} + +fn relative_path_between(base: &Path, target: &Path) -> Option { + let base_components = base.components().collect::>(); + let target_components = target.components().collect::>(); + let common_len = base_components + .iter() + .zip(&target_components) + .take_while(|(base, target)| base == target) + .count(); + if common_len == 0 { + return None; + } + + let mut relative = PathBuf::new(); + for component in &base_components[common_len..] { + match component { + std::path::Component::Normal(_) => relative.push(".."), + _ => return None, + } + } + for component in &target_components[common_len..] { + relative.push(component.as_os_str()); + } + if relative.as_os_str().is_empty() { + relative.push("."); + } + Some(relative) +} + +fn lock_relative_path(lock_path: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + lock_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) + } +} + +fn cache_source_path(nar_hash: &str) -> PathBuf { + cache_root().join("sources").join(cache_key(nar_hash)) +} + +fn cache_key(value: &str) -> String { + let mut key = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || byte == b'-' { + key.push(byte as char); + } else { + key.push_str(&format!("_{byte:02X}")); + } + } + key +} + +fn cache_root() -> PathBuf { + if let Some(path) = env::var_os("BRAINBREW_CACHE_DIR") { + return PathBuf::from(path); + } + + #[cfg(windows)] + { + if let Some(path) = env::var_os("LOCALAPPDATA") { + return PathBuf::from(path).join("BrainBrew").join("cache"); + } + } + + #[cfg(target_os = "macos")] + { + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home) + .join("Library") + .join("Caches") + .join("brainbrew"); + } + } + + if let Some(path) = env::var_os("XDG_CACHE_HOME") { + return PathBuf::from(path).join("brainbrew"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join(".cache").join("brainbrew"); + } + env::temp_dir().join("brainbrew-cache") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_repo_parses_only_plain_github_repo_urls() { + let repo = GithubRepo::parse("https://github.com/anki-geo/ultimate-geography.git") + .expect("GitHub HTTPS repo URL parses"); + assert_eq!(repo.owner, "anki-geo"); + assert_eq!(repo.name, "ultimate-geography"); + + assert!( + GithubRepo::parse("http://github.com/anki-geo/ultimate-geography").is_none(), + "production GitHub sources must be HTTPS" + ); + assert_eq!( + normalize_github_url("http://github.com/anki-geo/ultimate-geography/"), + "http://github.com/anki-geo/ultimate-geography/" + ); + + assert!( + GithubRepo::parse("https://github.com/anki-geo/ultimate-geography/tree/main").is_none() + ); + assert!(GithubRepo::parse("https://example.com/anki-geo/ultimate-geography.git").is_none()); + } + + #[test] + fn github_repo_builds_codeload_and_percent_encoded_api_urls() { + let repo = GithubRepo { + owner: "anki geo".to_owned(), + name: "ultimate/geography".to_owned(), + }; + + assert_eq!( + repo.codeload_tarball_url("abc123"), + "https://codeload.github.com/anki geo/ultimate/geography/tar.gz/abc123" + ); + assert_eq!( + repo.commit_api_url("feature/deck lock"), + "https://api.github.com/repos/anki geo/ultimate/geography/commits/feature%2Fdeck%20lock" + ); + } + + #[test] + fn non_github_git_url_reports_native_locking_error() { + let source = RequestedSource::Git { + url: "https://example.com/anki-geo/ultimate-geography.git".to_owned(), + reference: None, + rev: Some("abc123".to_owned()), + }; + + let error = fetch_git_source(&source, Some("sha256-example"), &FetchPolicy::default()) + .expect_err("non-GitHub URL is rejected before fetch"); + assert!(error.contains("native git locking currently supports GitHub HTTPS URLs")); + } + + #[test] + fn should_skip_source_entry_filters_vcs_build_and_nix_result_paths() { + for name in [ + ".git", + ".jj", + ".hg", + ".svn", + "target", + "result", + "result-doc", + ] { + assert!(package_tree::should_skip(name), "{name} should be skipped"); + } + for name in ["deck.yaml", "results", "target-notes", ".github"] { + assert!( + !package_tree::should_skip(name), + "{name} should be included" + ); + } + } + + #[test] + fn relative_path_between_sibling_checkout_paths_uses_dot_dot() { + let base = Path::new("/workspace/consumer"); + let target = Path::new("/workspace/package"); + + assert_eq!( + relative_path_between(base, target).unwrap(), + PathBuf::from("../package") + ); + } + + #[test] + fn verify_locked_manifest_metadata_reports_version_mismatch() { + let dir = TempDir::new().expect("temp dir"); + let manifest_path = dir.path().join("brainbrew.yaml"); + fs::write( + &manifest_path, + r#"package: + id: anki-geo.ultimate-geography + version: 0.2.0 +base: deck.yaml +overlays: {} +targets: {} +"#, + ) + .expect("write manifest"); + let package = LockedPackage { + manifest: "brainbrew.yaml".to_owned(), + package: LockedPackageMetadata { + version: "0.1.0".to_owned(), + }, + original: OriginalSource::Path { + path: ".".to_owned(), + }, + locked: LockedSource::Path { + path: ".".to_owned(), + nar_hash: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + }, + }; + + let error = verify_locked_manifest_metadata( + "anki-geo.ultimate-geography", + &package, + &manifest_path, + ) + .expect_err("version mismatch is rejected"); + assert!(error.contains("version mismatch")); + } + + #[test] + fn read_lock_reports_missing_and_corrupt_lock_files() { + let dir = TempDir::new().expect("temp dir"); + let missing = dir.path().join("missing.lock"); + let missing_error = read_lock(&missing).expect_err("missing lock file is rejected"); + assert!(missing_error.contains("missing.lock")); + + let corrupt = dir.path().join("brainbrew.lock"); + fs::write(&corrupt, "version: [\n").expect("write corrupt lock"); + let corrupt_error = read_lock(&corrupt).expect_err("corrupt lock file is rejected"); + assert!(corrupt_error.contains("failed to parse lock YAML")); + } + + #[test] + fn archive_staging_enforces_compressed_decompressed_and_ratio_budgets() { + let root = TempDir::new().unwrap(); + let archive = root.path().join("fixture.tar.gz"); + write_compressible_archive(&archive); + let source = archive.to_str().unwrap(); + + let compressed_policy = FetchPolicy { + max_download_bytes: 10, + ..FetchPolicy::default() + }; + let error = fetch_policy::fetch_to_temp(source, &compressed_policy, 10, None) + .expect_err("compressed bytes are bounded"); + assert!(error.contains("compressed_download_bytes"), "{error}"); + + let decompressed_policy = FetchPolicy { + max_decompressed_tar_bytes: 1024, + max_expansion_ratio: u64::MAX, + ..FetchPolicy::default() + }; + let download = fetch_policy::fetch_to_temp( + source, + &decompressed_policy, + decompressed_policy.max_download_bytes, + None, + ) + .unwrap(); + let destination = root.path().join("decompressed-out"); + let error = unpack_tarball(&download, &destination, &decompressed_policy, source) + .expect_err("decompressed tar bytes are bounded"); + assert!(error.contains("decompressed_tar_bytes"), "{error}"); + assert!(!destination.exists()); + + let ratio_policy = FetchPolicy { + max_expansion_ratio: 1, + ..FetchPolicy::default() + }; + let download = fetch_policy::fetch_to_temp( + source, + &ratio_policy, + ratio_policy.max_download_bytes, + None, + ) + .unwrap(); + let destination = root.path().join("ratio-out"); + let error = unpack_tarball(&download, &destination, &ratio_policy, source) + .expect_err("archive expansion ratio is bounded"); + assert!(error.contains("archive_expansion_ratio"), "{error}"); + assert!(!destination.exists()); + } + + #[test] + fn truncated_gzip_is_rejected_without_publishing_extraction_state() { + let root = TempDir::new().unwrap(); + let archive = root.path().join("truncated.tar.gz"); + fs::write(&archive, [0x1f, 0x8b, 0x08, 0x00, 0x00]).unwrap(); + let source = archive.to_str().unwrap(); + let policy = FetchPolicy::default(); + let download = + fetch_policy::fetch_to_temp(source, &policy, policy.max_download_bytes, None).unwrap(); + let destination = root.path().join("out"); + let error = unpack_tarball(&download, &destination, &policy, source) + .expect_err("truncated gzip must fail"); + assert!( + error.contains("truncated or invalid compressed archive"), + "{error}" + ); + assert!(!destination.exists()); + } + + fn write_compressible_archive(path: &Path) { + use flate2::Compression; + use flate2::write::GzEncoder; + use tar::{Builder, Header}; + + let output = fs::File::create(path).unwrap(); + let encoder = GzEncoder::new(output, Compression::best()); + let mut builder = Builder::new(encoder); + let bytes = vec![b'a'; 32 * 1024]; + let mut header = Header::new_gnu(); + header.set_path("pkg/repeated.txt").unwrap(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, bytes.as_slice()).unwrap(); + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); + } +} diff --git a/crates/brain-brew-cli/src/commands/media.rs b/crates/brain-brew-cli/src/commands/media.rs new file mode 100644 index 0000000..b900d54 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/media.rs @@ -0,0 +1,586 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_core::StableId; +use brain_brew_formats::media; +use brain_brew_formats::source_document::{ImageConversionReport, SourceDocumentEmission}; + +use crate::commands::lock::validated_nar_hash; +use crate::help; +use crate::io::{ + canonical_document_from_package, format_source_at, manifest_root, overlay_document_from_package, +}; +use crate::media_ownership::MediaRootSelections; +use crate::output; +use crate::package_resolver::{DiscoveryPolicy, apply_discovery_option}; +use crate::path_authorization::PathAuthorizer; +use crate::planner::{ + ManifestRegistry, MediaDeclarationProvenance, PlanSourceKind, RegistryManifest, + RegistrySourceKind, SourceProvenance, TargetPlan, +}; +use crate::workspace_mutation::{PlannedWorkspaceFile, commit_workspace_files, recover_workspace}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if matches!(args, [flag] if flag == "--help" || flag == "-h") + || matches!(args, [_, flag] if flag == "--help" || flag == "-h") + { + print!("{}", help::command("media").expect("media help exists")); + return Ok(()); + } + match args.first().map(String::as_str) { + Some("hash") => run_hash(&args[1..]), + Some("images-to-refs") => run_images_to_refs(&args[1..]), + _ => Err(help::usage_error( + "media", + "usage: brainbrew media hash|images-to-refs --manifest brainbrew.yaml (--all-targets | --target )", + )), + } +} + +fn run_hash(args: &[String]) -> Result<(), String> { + let args = parse_media_args(args, true)?; + let workspace_root = manifest_root(&args.manifest_path); + recover_workspace(&workspace_root)?; + let registry = ManifestRegistry::load_with_policy( + &args.manifest_path, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + let roots = MediaRootSelections::parse(®istry, &args.media_roots, &workspace_root)?; + let plans = selected_plans(®istry, args.target.as_deref(), args.all_targets)?; + let locked_before = snapshot_locked_packages(®istry)?; + + let mut declarations = BTreeMap::<(PathBuf, String), MediaDeclarationProvenance>::new(); + for plan in &plans { + roots.require_for_plan(plan)?; + for declaration in plan.media_declarations.values() { + if !declaration.source_kind.is_root_workspace() { + return Err(read_only_declaration_error("media hash", plan, declaration)); + } + let key = (declaration.source.clone(), declaration.id.clone()); + if let Some(previous) = declarations.insert(key, declaration.clone()) + && (previous.path != declaration.path || previous.sha256 != declaration.sha256) + { + return Err(format!( + "media hash found inconsistent final declarations for {} in {}", + declaration.id, + declaration.source.display() + )); + } + } + } + + let mut updates = BTreeMap::>::new(); + for declaration in declarations.into_values() { + let root = roots.require_for_declaration("media-hash mutation", &declaration)?; + let authorizer = PathAuthorizer::new( + format!("media for package {}", declaration.package_label()), + root, + )?; + let asset = authorizer + .authorize_read( + &declaration.source, + format!("media.{}.path", declaration.id), + &declaration.path, + ) + .map_err(|error| mutation_asset_error(&declaration, root, &error.to_string()))? + .into_path_buf(); + let bytes = fs::read(&asset) + .map_err(|error| mutation_asset_error(&declaration, root, &error.to_string()))?; + let actual = media::sha256_hex(&bytes); + if actual != declaration.sha256 { + updates + .entry(declaration.document_source.clone()) + .or_default() + .push((declaration, actual)); + } + } + + let changed_entries = updates.values().map(Vec::len).sum::(); + let mut replacements = BTreeMap::>::new(); + for (document_path, changes) in updates { + let source = plan_source(&plans, &document_path)?; + let loaded = registry_manifest_for_source(®istry, source)?; + match source.kind { + PlanSourceKind::Base => { + let mut document = canonical_document_from_package( + &document_path, + &loaded.root, + &loaded.include_roots, + )?; + for (declaration, hash) in changes { + document + .set_media_hash( + &StableId::new(&declaration.id).map_err(|error| error.to_string())?, + &declaration.path, + &hash, + ) + .map_err(|error| error.to_string())?; + } + collect_emission( + document.emit().map_err(|error| error.to_string())?, + &mut replacements, + )?; + } + PlanSourceKind::Overlay { .. } => { + let mut document = overlay_document_from_package( + &document_path, + &loaded.root, + &loaded.include_roots, + )?; + for (declaration, hash) in changes { + document + .set_media_hash( + &StableId::new(&declaration.id).map_err(|error| error.to_string())?, + &declaration.path, + &hash, + ) + .map_err(|error| error.to_string())?; + } + collect_emission( + document.emit().map_err(|error| error.to_string())?, + &mut replacements, + )?; + } + _ => { + return Err(format!( + "{} is not a mutable root source document", + document_path.display() + )); + } + } + } + + ensure_locked_packages_unchanged(®istry, &locked_before)?; + let files = planned_replacements(replacements)?; + let changed_files = files.len(); + commit_workspace_files(&workspace_root, files)?; + ensure_locked_packages_unchanged(®istry, &locked_before)?; + output::print_success( + "updated media hashes", + &[ + ("manifest", args.manifest_path.display().to_string()), + ("media roots", args.media_roots.join(", ")), + ("targets", plans.len().to_string()), + ("files changed", changed_files.to_string()), + ("entries changed", changed_entries.to_string()), + ], + ); + Ok(()) +} + +fn run_images_to_refs(args: &[String]) -> Result<(), String> { + let args = parse_media_args(args, false)?; + let workspace_root = manifest_root(&args.manifest_path); + recover_workspace(&workspace_root)?; + let registry = ManifestRegistry::load_with_policy( + &args.manifest_path, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + let plans = selected_plans(®istry, args.target.as_deref(), args.all_targets)?; + let locked_before = snapshot_locked_packages(®istry)?; + let lookup = media_path_lookup(&plans); + let sources = mutable_candidate_sources(&plans); + let mut replacements = BTreeMap::>::new(); + let mut report = ImageConversionReport::default(); + + for source in sources.values() { + let loaded = registry_manifest_for_source(®istry, source)?; + match source.kind { + PlanSourceKind::Base => { + let mut document = canonical_document_from_package( + &source.path, + &loaded.root, + &loaded.include_roots, + )?; + let local = document + .convert_strict_image_fields(&lookup) + .map_err(|error| error.to_string())?; + guard_source_mutability("media images-to-refs", source, local.converted)?; + add_report(&mut report, &local); + if local.converted > 0 { + collect_emission( + document.emit().map_err(|error| error.to_string())?, + &mut replacements, + )?; + } + } + PlanSourceKind::Overlay { .. } => { + let mut document = overlay_document_from_package( + &source.path, + &loaded.root, + &loaded.include_roots, + )?; + let local = document + .convert_strict_image_fields(&lookup) + .map_err(|error| error.to_string())?; + guard_source_mutability("media images-to-refs", source, local.converted)?; + add_report(&mut report, &local); + if local.converted > 0 { + collect_emission( + document.emit().map_err(|error| error.to_string())?, + &mut replacements, + )?; + } + } + _ => {} + } + } + + ensure_locked_packages_unchanged(®istry, &locked_before)?; + let files = planned_replacements(replacements)?; + let changed_files = files.len(); + commit_workspace_files(&workspace_root, files)?; + ensure_locked_packages_unchanged(®istry, &locked_before)?; + output::print_success( + "converted strict image fields to !image references", + &[ + ("manifest", args.manifest_path.display().to_string()), + ("targets", plans.len().to_string()), + ("files changed", changed_files.to_string()), + ("converted fields", report.converted.to_string()), + ( + "skipped non-strict image fields", + report.skipped_non_strict.to_string(), + ), + ( + "skipped no media match", + report.skipped_no_match.to_string(), + ), + ( + "skipped ambiguous media path", + report.skipped_ambiguous_path.to_string(), + ), + ], + ); + Ok(()) +} + +struct MediaArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + media_roots: Vec, + include_paths: Vec, + package_roots: Vec, + discovery_policy: DiscoveryPolicy, +} + +fn parse_media_args(args: &[String], require_roots: bool) -> Result { + let mut parsed = MediaArgs { + manifest_path: PathBuf::from("brainbrew.yaml"), + target: None, + all_targets: false, + media_roots: Vec::new(), + include_paths: Vec::new(), + package_roots: Vec::new(), + discovery_policy: DiscoveryPolicy::default(), + }; + let mut index = 0; + while index < args.len() { + let value = |name: &str, index: usize| { + args.get(index + 1) + .cloned() + .ok_or_else(|| format!("{name} requires a value")) + }; + match args[index].as_str() { + "--manifest" => { + parsed.manifest_path = PathBuf::from(value("--manifest", index)?); + index += 2; + } + "--target" => { + parsed.target = Some(value("--target", index)?); + index += 2; + } + "--all-targets" => { + parsed.all_targets = true; + index += 1; + } + "--media-root" => { + parsed.media_roots.push(value("--media-root", index)?); + index += 2; + } + "--include" => { + parsed + .include_paths + .push(PathBuf::from(value("--include", index)?)); + index += 2; + } + "--package-root" => { + parsed + .package_roots + .push(PathBuf::from(value("--package-root", index)?)); + index += 2; + } + flag @ ("--discovery-max-depth" + | "--discovery-max-entries" + | "--discovery-max-manifests" + | "--package-ignore") => { + let selected = value(flag, index)?; + apply_discovery_option(flag, &selected, &mut parsed.discovery_policy)?; + index += 2; + } + other => return Err(format!("unexpected media argument {other:?}")), + } + } + if parsed.all_targets == parsed.target.is_some() { + return Err( + "media command requires exactly one of --all-targets or --target ".to_owned(), + ); + } + if require_roots && parsed.media_roots.is_empty() { + return Err("media hash requires --media-root".to_owned()); + } + if !require_roots && !parsed.media_roots.is_empty() { + return Err("media images-to-refs does not use --media-root".to_owned()); + } + Ok(parsed) +} + +fn selected_plans( + registry: &ManifestRegistry, + target: Option<&str>, + all_targets: bool, +) -> Result, String> { + let references = if all_targets { + registry + .root() + .manifest + .targets + .keys() + .map(|target| { + registry + .root() + .identity + .as_ref() + .map(|identity| format!("{}:{target}", identity.id)) + .unwrap_or_else(|| target.clone()) + }) + .collect::>() + } else { + vec![target.expect("parser requires target").to_owned()] + }; + references + .iter() + .map(|target| registry.plan(target)) + .collect() +} + +fn mutable_candidate_sources(plans: &[TargetPlan]) -> BTreeMap { + let mut sources = BTreeMap::new(); + for plan in plans { + for source in plan.sources() { + if matches!( + source.kind, + PlanSourceKind::Base | PlanSourceKind::Overlay { .. } + ) { + sources + .entry(source.path.clone()) + .or_insert_with(|| source.clone()); + } + } + } + sources +} + +fn media_path_lookup(plans: &[TargetPlan]) -> BTreeMap> { + let mut lookup = BTreeMap::new(); + for declaration in plans + .iter() + .flat_map(|plan| plan.media_declarations.values()) + { + let id = StableId::new(&declaration.id).expect("planned media ID is valid"); + lookup + .entry(declaration.path.clone()) + .and_modify(|existing: &mut Option| { + if existing.as_ref() != Some(&id) { + *existing = None; + } + }) + .or_insert(Some(id)); + } + lookup +} + +fn registry_manifest_for_source<'a>( + registry: &'a ManifestRegistry, + source: &SourceProvenance, +) -> Result<&'a RegistryManifest, String> { + registry + .manifests() + .iter() + .find(|loaded| loaded.path == source.manifest && loaded.root == source.package_root) + .ok_or_else(|| format!("no registry package owns source {}", source.path.display())) +} + +fn plan_source<'a>(plans: &'a [TargetPlan], path: &Path) -> Result<&'a SourceProvenance, String> { + plans + .iter() + .flat_map(TargetPlan::sources) + .find(|source| source.path == path) + .ok_or_else(|| format!("no target plan owns source {}", path.display())) +} + +fn collect_emission( + emission: SourceDocumentEmission, + replacements: &mut BTreeMap>, +) -> Result<(), String> { + collect_source_file(emission.root(), replacements)?; + for included in emission.included() { + collect_source_file(included, replacements)?; + } + Ok(()) +} + +fn collect_source_file( + source: &brain_brew_formats::source_document::SourceFile, + replacements: &mut BTreeMap>, +) -> Result<(), String> { + let path = PathBuf::from(source.provenance().source_name()); + let bytes = source.text().as_bytes().to_vec(); + if let Some(previous) = replacements.insert(path.clone(), bytes.clone()) + && previous != bytes + { + return Err(format!( + "conflicting mutation outputs for {}", + path.display() + )); + } + Ok(()) +} + +fn planned_replacements( + replacements: BTreeMap>, +) -> Result, String> { + let mut planned = Vec::new(); + for (path, replacement) in replacements { + let original = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; + if original == replacement { + continue; + } + let validation_path = path.clone(); + planned.push(PlannedWorkspaceFile::validated( + path, + original, + replacement, + move |bytes| { + let text = std::str::from_utf8(bytes).map_err(|error| error.to_string())?; + let formatted = format_source_at(&validation_path, text)?; + if formatted == text { + Ok(()) + } else { + Err("replacement is not canonical Brain Brew source".to_owned()) + } + }, + )?); + } + Ok(planned) +} + +fn guard_source_mutability( + command: &str, + source: &SourceProvenance, + changes: usize, +) -> Result<(), String> { + if changes == 0 || source.registry_source.is_root_workspace() { + return Ok(()); + } + Err(format!( + "{command} is read-only for {} package source {} ({}) at {}; requested operation would mutate {changes} field(s). Dependency/include/locked sources cannot be vendored by this command", + source + .package + .as_ref() + .map(|package| package.id.as_str()) + .unwrap_or("unnamed"), + source.registry_source.ownership_name(), + source.kind_name(), + source.path.display() + )) +} + +trait SourceKindName { + fn kind_name(&self) -> &'static str; +} + +impl SourceKindName for SourceProvenance { + fn kind_name(&self) -> &'static str { + match self.kind { + PlanSourceKind::Base => "base", + PlanSourceKind::Overlay { .. } => "overlay", + PlanSourceKind::ScalarInclude { .. } => "scalar include", + PlanSourceKind::MediaInclude => "media include", + } + } +} + +fn read_only_declaration_error( + command: &str, + plan: &TargetPlan, + declaration: &MediaDeclarationProvenance, +) -> String { + format!( + "{command} is read-only for target {}, package {}, declaration {}, path {:?}, source {} ({}) at {}; dependency/include/locked declarations cannot be mutated", + plan.qualified_name, + declaration.package_label(), + declaration.id, + declaration.path, + declaration.source_kind.ownership_name(), + declaration.source_kind.ownership_name(), + declaration.source.display() + ) +} + +fn mutation_asset_error( + declaration: &MediaDeclarationProvenance, + root: &Path, + reason: &str, +) -> String { + format!( + "media hash asset error for package {}, declaration {}, path {:?}, root {}: {reason}", + declaration.package_label(), + declaration.id, + declaration.path, + root.display() + ) +} + +fn snapshot_locked_packages( + registry: &ManifestRegistry, +) -> Result, String> { + registry + .manifests() + .iter() + .filter(|loaded| loaded.discovery == RegistrySourceKind::SiblingLock) + .map(|loaded| { + Ok(( + loaded.root.clone(), + validated_nar_hash(&loaded.root, "locked package tree")?, + )) + }) + .collect() +} + +fn ensure_locked_packages_unchanged( + registry: &ManifestRegistry, + expected: &BTreeMap, +) -> Result<(), String> { + let actual = snapshot_locked_packages(registry)?; + if &actual == expected { + Ok(()) + } else { + Err(format!( + "locked/cache package integrity changed during media mutation; expected {expected:?}, found {actual:?}; cache was not repaired" + )) + } +} + +fn add_report(total: &mut ImageConversionReport, local: &ImageConversionReport) { + total.converted += local.converted; + total.skipped_non_strict += local.skipped_non_strict; + total.skipped_no_match += local.skipped_no_match; + total.skipped_ambiguous_path += local.skipped_ambiguous_path; +} diff --git a/crates/brain-brew-cli/src/commands/mod.rs b/crates/brain-brew-cli/src/commands/mod.rs new file mode 100644 index 0000000..75e8fcc --- /dev/null +++ b/crates/brain-brew-cli/src/commands/mod.rs @@ -0,0 +1,14 @@ +pub(crate) mod compose; +pub(crate) mod diff; +pub(crate) mod explain; +pub(crate) mod export; +pub(crate) mod fmt; +pub(crate) mod import; +pub(crate) mod lock; +pub(crate) mod media; +pub(crate) mod targets; +pub(crate) mod translation_overlay; +pub(crate) mod translations; +pub(crate) mod validate; +pub(crate) mod verify; +pub(crate) mod workbench; diff --git a/crates/brain-brew-cli/src/commands/targets.rs b/crates/brain-brew-cli/src/commands/targets.rs new file mode 100644 index 0000000..d03020e --- /dev/null +++ b/crates/brain-brew-cli/src/commands/targets.rs @@ -0,0 +1,100 @@ +use serde_json::json; + +use crate::args::{parse_targets_args, split_json_flag}; +use crate::output::package_json; +use crate::planner::ManifestRegistry; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + let (json_output, rest) = split_json_flag(args); + let target_args = parse_targets_args(&rest)?; + let registry = if let Some(root_manifest) = target_args.manifest_paths.first() { + let explicit = target_args + .manifest_paths + .iter() + .skip(1) + .cloned() + .collect::>(); + ManifestRegistry::load_with_policy( + root_manifest, + &explicit, + &target_args.package_roots, + &target_args.discovery_policy, + )? + } else { + ManifestRegistry::discover(&target_args.package_roots, &target_args.discovery_policy)? + }; + + if json_output { + let mut packages = Vec::new(); + let mut all_targets = Vec::new(); + for loaded in registry.manifests() { + let mut targets = Vec::new(); + for target in loaded.manifest.targets.keys() { + let reference = loaded + .identity + .as_ref() + .map(|identity| format!("{}:{target}", identity.id)) + .unwrap_or_else(|| target.clone()); + let plan = registry.plan(&reference)?; + let overlays = plan + .overlays + .iter() + .map(|(overlay, _)| { + json!({ + "id": overlay.id, + "qualified_id": overlay.qualified_id, + "file": overlay.display_file, + "kind": format!("{:?}", overlay.kind).to_ascii_lowercase(), + "declared_kind": overlay.declared_kind, + "package": overlay.package.as_ref().map(|package| json!({ + "id": package.id, + "version": package.version, + })), + }) + }) + .collect::>(); + let value = json!({ + "name": target, + "qualified_name": plan.qualified_name, + "extends": loaded.manifest.targets[target].extends.as_ref(), + "overlays": overlays, + }); + targets.push(value.clone()); + all_targets.push(value); + } + packages.push(json!({ + "manifest": loaded.path.display().to_string(), + "package": loaded.manifest.package.as_ref().map(package_json), + "targets": targets, + })); + } + let package = registry.root().manifest.package.as_ref().map(package_json); + let stats = registry.discovery_stats(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "package": package, + "targets": all_targets, + "packages": packages, + "discovery": { + "roots_inspected": stats.roots_inspected, + "entries_inspected": stats.entries_inspected, + "directories_inspected": stats.directories_inspected, + "regular_files_inspected": stats.regular_files_inspected, + "manifests_found": stats.manifests_found, + "built_in_pruned": stats.built_in_pruned, + "configured_pruned": stats.configured_pruned, + } + })) + .unwrap() + ); + } else { + for target in registry.target_references() { + // Planning is intentional: text and JSON listings reject the same + // missing refs, cycles, identity mismatches, and ambiguous graph. + registry.plan(&target)?; + println!("{target}"); + } + } + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/translation_overlay.rs b/crates/brain-brew-cli/src/commands/translation_overlay.rs new file mode 100644 index 0000000..0036197 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/translation_overlay.rs @@ -0,0 +1,78 @@ +use brain_brew_core::{ + CanonicalDeck, FieldGraphReport, Overlay, TranslationCoverageCategory, + TranslationDictionaryRepair, +}; + +pub(crate) fn compose_lenient_translation_overlay( + current: &CanonicalDeck, + overlay: &Overlay, +) -> Result { + let sanitized = sanitize_lenient_translation_overlay(current, overlay) + .map_err(|error| format!("failed to resolve translation source fields: {error}"))?; + current.compose(&[sanitized]).map_err(|error| { + format!( + "failed to compose translation overlay {}: {error}", + overlay.id + ) + }) +} + +pub(crate) fn sanitize_lenient_translation_overlay( + current: &CanonicalDeck, + overlay: &Overlay, +) -> Result { + let mut sanitized = overlay.clone(); + if let Some(translations) = &mut sanitized.translations { + let report = current.translation_coverage(overlay)?; + let mut repairs = vec![TranslationDictionaryRepair::SetRequireComplete(false)]; + for entry in report.entries { + match entry.category { + TranslationCoverageCategory::StaleDirectKey => { + repairs.push(TranslationDictionaryRepair::RemoveDirect { + source: entry.source, + }); + } + TranslationCoverageCategory::StaleContextualKey => { + if let Some(context) = entry.context { + repairs.push(TranslationDictionaryRepair::RemoveContextual { + context, + source: entry.source, + }); + } + } + TranslationCoverageCategory::StaleNoChangeKey => { + repairs.push(TranslationDictionaryRepair::RemoveNoChange { + source: entry.source, + }); + } + TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::InvalidTargetAdaptation => { + repairs.push(TranslationDictionaryRepair::RemoveTargetAdaptation { + path: entry.context.unwrap_or(entry.path), + }); + } + TranslationCoverageCategory::StaleVariableKey => { + if let Some(key) = entry.context { + repairs.push(TranslationDictionaryRepair::RemoveVariable { + key, + source: entry.source, + }); + } + } + TranslationCoverageCategory::StaleAdapterIdKey => { + if let Some(key) = entry.context { + repairs.push(TranslationDictionaryRepair::RemoveAdapterId { + key, + source: entry.source, + }); + } + } + _ => {} + } + } + translations + .apply_repairs(&repairs) + .expect("coverage report repairs refer to entries in this dictionary"); + } + Ok(sanitized) +} diff --git a/crates/brain-brew-cli/src/commands/translations.rs b/crates/brain-brew-cli/src/commands/translations.rs new file mode 100644 index 0000000..b1ef6d8 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/translations.rs @@ -0,0 +1,3176 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +use brain_brew_core::{ + Overlay, OverlayKind, TranslationContextUnit, TranslationContextView, + TranslationCoverageCategory, TranslationCoverageEntry, TranslationCoverageReport, + TranslationMessageContext, +}; +use brain_brew_formats::manifest::FederatedDeckManifest; +use brain_brew_formats::overlay_source_document::{OverlaySourceDocument, TranslationStubs}; +use brain_brew_formats::yaml_scalar::scalar as yaml_scalar; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use serde_json::json; + +use crate::commands::translation_overlay::compose_lenient_translation_overlay; +use crate::help; +use crate::io::{manifest_root, overlay_from_source_text, overlay_source_document, read_manifest}; +use crate::output; +use crate::package_resolver::{DiscoveryPolicy, apply_discovery_option}; +use crate::planner::{ManifestRegistry, PlannedOverlay, plan_manifest_target}; +use crate::workspace_mutation::{PlannedWorkspaceFile, commit_workspace_files, recover_workspace}; + +const TRANSLATION_JSON_SCHEMA_VERSION: u32 = 1; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!( + "{}", + help::command("translations").expect("translations help exists") + ); + return Ok(()); + } + + let mut args = parse_translation_args(args)?; + let interactive = should_use_interactive(&args); + if !args.manifest_path.exists() && !interactive { + return Err(missing_manifest_error( + &args.manifest_path, + &args.discovery_policy, + )?); + } + + if interactive { + let raw_mode = io::stdin().is_terminal() && io::stdout().is_terminal(); + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + configure_interactively(&mut args, &mut reader, &mut writer, raw_mode)?; + } + + if args.apply || args.resolve_action.is_some() { + recover_workspace(&manifest_root(&args.manifest_path))?; + } + + let reports = collect_translation_reports(&args)?; + + if args.resolve_action.is_some() { + let applied = resolve_stale_translations(&args, &reports)?; + if args.json_output { + print_translation_json("stale_resolution", json!({ "resolved": applied })); + } else { + let total = applied.values().sum::(); + let noun = if total == 1 { + "translation" + } else { + "translations" + }; + let details = applied + .iter() + .map(|(file, count)| (file.as_str(), count.to_string())) + .collect::>(); + output::print_success(format!("resolved {total} stale {noun}"), &details); + } + return Ok(()); + } + + let mut edits_by_file = BTreeMap::::new(); + if args.apply { + if interactive { + let raw_mode = io::stdin().is_terminal() && io::stdout().is_terminal(); + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + edits_by_file = + prompt_selective_apply(&reports, &mut reader, &mut writer, raw_mode, args.full)?; + } else { + edits_by_file = direct_stub_edits_from_reports(&reports, args.full); + } + } + + let planned = edits_by_file + .iter() + .map(|(path, edits)| plan_overlay_edits(path, edits)) + .collect::, String>>()?; + let applied = commit_translation_documents(&args, planned)?; + + if args.context { + if args.json_output { + print_json_contexts(&reports); + } else if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_contexts(&reports, args.full); + } + } else if args.summary { + if args.json_output { + print_json_summary(&reports); + } else if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_summary(&reports, args.full); + } + } else if args.json_output { + print_json_reports(&reports, &applied); + } else { + if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_reports(&reports, args.full); + } + if args.apply { + let total = applied.values().sum::(); + let details = applied + .iter() + .map(|(file, count)| (file.as_str(), count.to_string())) + .collect::>(); + output::print_success(format!("applied {total} translation edit(s)"), &details); + } + } + + Ok(()) +} + +#[derive(Clone)] +struct TranslationArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + include_paths: Vec, + package_roots: Vec, + discovery_policy: DiscoveryPolicy, + language: Option, + overlay: Option, + note: Option, + field: Option, + path_prefixes: Vec, + apply: bool, + json_output: bool, + interactive: Option, + full: bool, + summary: bool, + context: bool, + source: Option, + duplicates: bool, + status: Option, + resolve_action: Option, + old_source: Option, + new_source: Option, + stale_context: Option, + replacement_translation: Option, +} + +fn parse_translation_args(args: &[String]) -> Result { + let mut parsed = TranslationArgs { + manifest_path: PathBuf::from("brainbrew.yaml"), + target: None, + all_targets: false, + include_paths: Vec::new(), + package_roots: Vec::new(), + discovery_policy: DiscoveryPolicy::default(), + language: None, + overlay: None, + note: None, + field: None, + path_prefixes: Vec::new(), + apply: false, + json_output: false, + interactive: None, + full: false, + summary: false, + context: false, + source: None, + duplicates: false, + status: None, + resolve_action: None, + old_source: None, + new_source: None, + stale_context: None, + replacement_translation: None, + }; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" => { + let Some(path) = args.get(index + 1) else { + return Err("--manifest requires a path".to_owned()); + }; + parsed.manifest_path = PathBuf::from(path); + index += 2; + } + "--target" => { + let Some(name) = args.get(index + 1) else { + return Err("--target requires a name".to_owned()); + }; + parsed.target = Some(name.clone()); + index += 2; + } + "--all-targets" => { + parsed.all_targets = true; + index += 1; + } + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + parsed.include_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + parsed.package_roots.push(PathBuf::from(path)); + index += 2; + } + flag @ ("--discovery-max-depth" + | "--discovery-max-entries" + | "--discovery-max-manifests" + | "--package-ignore") => { + let value = args + .get(index + 1) + .ok_or_else(|| format!("{flag} requires a value"))?; + apply_discovery_option(flag, value, &mut parsed.discovery_policy)?; + index += 2; + } + "--language" => { + let Some(language) = args.get(index + 1) else { + return Err("--language requires a code".to_owned()); + }; + parsed.language = Some(language.clone()); + index += 2; + } + "--overlay" => { + let Some(overlay) = args.get(index + 1) else { + return Err("--overlay requires an id or file fragment".to_owned()); + }; + parsed.overlay = Some(overlay.clone()); + index += 2; + } + "--note" => { + let Some(note) = args.get(index + 1) else { + return Err("--note requires a note id".to_owned()); + }; + parsed.note = Some(note.clone()); + index += 2; + } + "--field" => { + let Some(field) = args.get(index + 1) else { + return Err("--field requires a field id".to_owned()); + }; + parsed.field = Some(field.clone()); + index += 2; + } + "--path-prefix" => { + let Some(prefix) = args.get(index + 1) else { + return Err("--path-prefix requires a deck path prefix".to_owned()); + }; + parsed.path_prefixes.push(prefix.clone()); + index += 2; + } + "--apply" => { + parsed.apply = true; + index += 1; + } + "--json" => { + parsed.json_output = true; + index += 1; + } + "--full" => { + parsed.full = true; + index += 1; + } + "--summary" => { + parsed.summary = true; + index += 1; + } + "--context" => { + parsed.context = true; + index += 1; + } + "--source" => { + let Some(source) = args.get(index + 1) else { + return Err("--source requires text to match".to_owned()); + }; + parsed.source = Some(source.clone()); + index += 2; + } + "--duplicates" => { + parsed.duplicates = true; + index += 1; + } + "--status" => { + let Some(status) = args.get(index + 1) else { + return Err("--status requires a status filter".to_owned()); + }; + validate_status_filter(status)?; + parsed.status = Some(status.clone()); + index += 2; + } + "--resolve" => { + let Some(action) = args.get(index + 1) else { + return Err("--resolve requires confirm or replace".to_owned()); + }; + parsed.resolve_action = Some(parse_stale_resolve_action(action)?); + index += 2; + } + "--old-source" => { + let Some(source) = args.get(index + 1) else { + return Err("--old-source requires source text".to_owned()); + }; + parsed.old_source = Some(source.clone()); + index += 2; + } + "--new-source" => { + let Some(source) = args.get(index + 1) else { + return Err("--new-source requires source text".to_owned()); + }; + parsed.new_source = Some(source.clone()); + index += 2; + } + "--stale-context" => { + let Some(context) = args.get(index + 1) else { + return Err("--stale-context requires a dictionary context path".to_owned()); + }; + parsed.stale_context = Some(context.clone()); + index += 2; + } + "--translation" => { + let Some(translation) = args.get(index + 1) else { + return Err("--translation requires translated text".to_owned()); + }; + parsed.replacement_translation = Some(translation.clone()); + index += 2; + } + "--interactive" => { + parsed.interactive = Some(true); + index += 1; + } + "--no-interactive" => { + parsed.interactive = Some(false); + index += 1; + } + other => return Err(format!("unexpected translations argument {other:?}")), + } + } + if parsed.all_targets && parsed.target.is_some() { + return Err("choose --all-targets or --target, not both".to_owned()); + } + if parsed.interactive == Some(true) && parsed.json_output { + return Err("choose --interactive or --json, not both".to_owned()); + } + if parsed.summary && parsed.apply { + return Err("choose --summary or --apply, not both".to_owned()); + } + if parsed.summary && parsed.context { + return Err("choose --summary or --context, not both".to_owned()); + } + if parsed.resolve_action.is_some() { + if parsed.apply { + return Err("choose --resolve or --apply, not both".to_owned()); + } + if parsed.summary || parsed.context { + return Err("choose --resolve or reporting views, not both".to_owned()); + } + if parsed.resolve_action == Some(StaleResolveAction::Replace) + && parsed.replacement_translation.is_none() + { + return Err("--resolve replace requires --translation".to_owned()); + } + if parsed.resolve_action == Some(StaleResolveAction::Confirm) + && parsed.replacement_translation.is_some() + { + return Err("--translation is only valid with --resolve replace".to_owned()); + } + } else if parsed.old_source.is_some() + || parsed.new_source.is_some() + || parsed.stale_context.is_some() + || parsed.replacement_translation.is_some() + { + return Err( + "--old-source, --new-source, --stale-context, and --translation require --resolve" + .to_owned(), + ); + } + Ok(parsed) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StaleResolveAction { + Confirm, + Replace, +} + +fn parse_stale_resolve_action(value: &str) -> Result { + match value { + "confirm" => Ok(StaleResolveAction::Confirm), + "replace" => Ok(StaleResolveAction::Replace), + other => Err(format!( + "invalid stale translation resolve action {other:?}; expected confirm or replace" + )), + } +} + +fn should_use_interactive(args: &TranslationArgs) -> bool { + match args.interactive { + Some(true) => !args.json_output, + Some(false) => false, + None => { + !args.json_output + && io::stdin().is_terminal() + && io::stdout().is_terminal() + && (!args.manifest_path.exists() || (args.target.is_none() && !args.all_targets)) + } + } +} + +fn configure_interactively( + args: &mut TranslationArgs, + reader: &mut R, + writer: &mut W, + raw_mode: bool, +) -> Result<(), String> { + let mut ui = TerminalUi::new(reader, writer, raw_mode)?; + ui.message("Brain Brew translation coverage")?; + + if !args.manifest_path.exists() { + let manifests = discover_nearby_manifests(&args.discovery_policy)?; + if manifests.is_empty() { + return Err(missing_manifest_error( + &args.manifest_path, + &args.discovery_policy, + )?); + } + let labels = manifests + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + let choice = ui.select_one("Manifest", &labels, 0)?; + args.manifest_path = manifests[choice].clone(); + } + + let manifest = read_manifest(&args.manifest_path)?; + if args.target.is_none() && !args.all_targets { + let mut target_values = manifest.targets.keys().cloned().collect::>(); + target_values.sort(); + let mut labels = target_values.clone(); + labels.push("all targets".to_owned()); + let choice = ui.select_one("Target", &labels, 0)?; + if choice == target_values.len() { + args.all_targets = true; + } else { + args.target = Some(target_values[choice].clone()); + } + } + + if args.language.is_none() && args.all_targets { + let languages = inferred_languages(&manifest); + if !languages.is_empty() { + let mut labels = vec!["all languages".to_owned()]; + labels.extend(languages.iter().cloned()); + let choice = ui.select_one("Language filter", &labels, 0)?; + if choice > 0 { + args.language = Some(languages[choice - 1].clone()); + } + } + } + + if args.overlay.is_none() { + let overlays = translation_overlay_choices(args)?; + if overlays.len() > 1 { + let mut labels = vec!["all translation overlays".to_owned()]; + labels.extend(overlays.iter().map(|overlay| overlay.label.clone())); + let choice = ui.select_one("Translation overlay", &labels, 0)?; + if choice > 0 { + args.overlay = Some(overlays[choice - 1].id.clone()); + } + } + } + + if args.note.is_none() && args.field.is_none() && args.path_prefixes.is_empty() { + let reports = collect_translation_reports(args)?; + configure_scope_interactively(args, &reports, &mut ui)?; + } + + if !args.apply { + let labels = vec![ + "report only".to_owned(), + "apply selected source→source stubs".to_owned(), + ]; + let choice = ui.select_one("Mode", &labels, 0)?; + if choice == 1 { + args.apply = true; + } + } + + ui.finish_with_equivalent_command(args) +} + +fn configure_scope_interactively( + args: &mut TranslationArgs, + reports: &[ScopedTranslationReport], + ui: &mut TerminalUi<'_, R, W>, +) -> Result<(), String> { + let mut notes = BTreeSet::new(); + let mut fields = BTreeSet::new(); + let mut problem_paths = BTreeSet::new(); + for report in reports { + for entry in &report.report.entries { + if let Some(note) = note_id_from_path(&entry.path) { + notes.insert(note); + } + if let Some(field) = field_id_from_path(&entry.path) { + fields.insert(field); + } + if entry.category.is_problem() { + problem_paths.insert(entry.path.clone()); + } + } + } + + let labels = vec![ + "whole overlay".to_owned(), + "select note".to_owned(), + "select field".to_owned(), + "select path prefix".to_owned(), + "changed-path prefixes from diff".to_owned(), + ]; + let choice = ui.select_one("Scope", &labels, 0)?; + match choice { + 0 => {} + 1 => { + let note_labels = notes.into_iter().collect::>(); + if note_labels.is_empty() { + ui.message("No note paths were found; using whole overlay.")?; + } else { + let choice = ui.select_one("Note", ¬e_labels, 0)?; + args.note = Some(note_labels[choice].clone()); + } + } + 2 => { + let field_labels = fields.into_iter().collect::>(); + if field_labels.is_empty() { + ui.message("No field paths were found; using whole overlay.")?; + } else { + let choice = ui.select_one("Field", &field_labels, 0)?; + args.field = Some(field_labels[choice].clone()); + } + } + 3 => { + let mut body = Vec::new(); + if !problem_paths.is_empty() { + body.push("Known problem paths:".to_owned()); + for path in problem_paths.iter().take(12) { + body.push(format!(" - {path}")); + } + } + let prefix = ui.prompt_text("Path prefix", &body)?; + if !prefix.trim().is_empty() { + args.path_prefixes.push(prefix.trim().to_owned()); + } + } + 4 => { + let prefixes = ui.prompt_text( + "Changed deck path prefixes", + &["Paste changed deck path prefixes, comma-separated.".to_owned()], + )?; + args.path_prefixes.extend( + prefixes + .split(',') + .map(str::trim) + .filter(|prefix| !prefix.is_empty()) + .map(str::to_owned), + ); + } + _ => unreachable!(), + } + Ok(()) +} + +struct TerminalUi<'a, R: Read, W: Write> { + reader: &'a mut R, + writer: &'a mut W, + raw_mode: bool, + max_option_rows: Option, + terminal_cols: Option, +} + +impl<'a, R: Read, W: Write> TerminalUi<'a, R, W> { + fn new(reader: &'a mut R, writer: &'a mut W, raw_mode: bool) -> Result { + let (terminal_cols, max_option_rows) = if raw_mode { + enable_raw_mode().map_err(|error| error.to_string())?; + write!(writer, "\x1b[?25l").map_err(|error| error.to_string())?; + match crossterm::terminal::size() { + Ok((cols, rows)) => ( + Some(cols as usize), + Some(usize::from(rows.saturating_sub(6).max(3))), + ), + Err(_) => (None, Some(12)), + } + } else { + (None, None) + }; + Ok(Self { + reader, + writer, + raw_mode, + max_option_rows, + terminal_cols, + }) + } + + fn message(&mut self, message: &str) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(message, "1;36"))?; + self.blank_line()?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn finish_with_equivalent_command(&mut self, args: &TranslationArgs) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line("Equivalent command:")?; + self.write_line(&format!(" {}", equivalent_command(args)))?; + self.blank_line()?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn select_one( + &mut self, + label: &str, + options: &[String], + default_index: usize, + ) -> Result { + if options.is_empty() { + return Err(format!("no {label} options are available")); + } + let mut selected = default_index.min(options.len() - 1); + loop { + self.render_select_one(label, options, selected)?; + match self.read_key()? { + TuiKey::Up => selected = selected.saturating_sub(1), + TuiKey::Down => selected = (selected + 1).min(options.len() - 1), + TuiKey::PageUp => { + selected = selected.saturating_sub(self.visible_option_rows(options.len())) + } + TuiKey::PageDown => { + selected = + (selected + self.visible_option_rows(options.len())).min(options.len() - 1) + } + TuiKey::Home => selected = 0, + TuiKey::End => selected = options.len() - 1, + TuiKey::Enter => return Ok(selected), + TuiKey::Char('k') => selected = selected.saturating_sub(1), + TuiKey::Char('j') => selected = (selected + 1).min(options.len() - 1), + TuiKey::Char('q') | TuiKey::Esc => { + return Err("interactive selection cancelled".to_owned()); + } + _ => {} + } + } + } + + fn select_many( + &mut self, + label: &str, + options: &[String], + selected_by_default: bool, + ) -> Result, String> { + if options.is_empty() { + return Ok(Vec::new()); + } + let mut cursor = 0; + let mut selected = vec![selected_by_default; options.len()]; + loop { + self.render_select_many(label, options, &selected, cursor)?; + match self.read_key()? { + TuiKey::Up => cursor = cursor.saturating_sub(1), + TuiKey::Down => cursor = (cursor + 1).min(options.len() - 1), + TuiKey::PageUp => { + cursor = cursor.saturating_sub(self.visible_option_rows(options.len())) + } + TuiKey::PageDown => { + cursor = + (cursor + self.visible_option_rows(options.len())).min(options.len() - 1) + } + TuiKey::Home => cursor = 0, + TuiKey::End => cursor = options.len() - 1, + TuiKey::Char('k') => cursor = cursor.saturating_sub(1), + TuiKey::Char('j') => cursor = (cursor + 1).min(options.len() - 1), + TuiKey::Space => selected[cursor] = !selected[cursor], + TuiKey::Char('a') => { + let all_selected = selected.iter().all(|value| *value); + selected.fill(!all_selected); + } + TuiKey::Enter => { + return Ok(selected + .iter() + .enumerate() + .filter_map(|(index, selected)| selected.then_some(index)) + .collect()); + } + TuiKey::Char('q') | TuiKey::Esc => { + return Err("interactive selection cancelled".to_owned()); + } + _ => {} + } + } + } + + fn prompt_text(&mut self, label: &str, body: &[String]) -> Result { + let mut input = String::new(); + loop { + self.render_text_prompt(label, body, &input)?; + match self.read_key()? { + TuiKey::Enter => return Ok(input), + TuiKey::Backspace => { + input.pop(); + } + TuiKey::Char(ch) => input.push(ch), + TuiKey::Esc => return Err("interactive text entry cancelled".to_owned()), + _ => {} + } + } + } + + fn render_select_one( + &mut self, + label: &str, + options: &[String], + selected: usize, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + let (start, end, scrolled) = self.option_window(options.len(), selected); + if scrolled { + self.write_line(&format!( + "Showing {}–{} of {}", + start + 1, + end, + options.len() + ))?; + self.blank_line()?; + } + for (relative_index, option) in options[start..end].iter().enumerate() { + let index = start + relative_index; + let marker = if index == selected { "›" } else { " " }; + let option = self.truncate_option(option, 4); + let option = if index == selected { + color_stdout(&option, "1;32") + } else { + option + }; + self.write_line(&format!(" {marker} {option}"))?; + } + self.blank_line()?; + self.write_line("↑/↓ move • PgUp/PgDn jump • Enter select • q cancel")?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn render_select_many( + &mut self, + label: &str, + options: &[String], + selected: &[bool], + cursor: usize, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + let (start, end, scrolled) = self.option_window(options.len(), cursor); + if scrolled { + self.write_line(&format!( + "Showing {}–{} of {}", + start + 1, + end, + options.len() + ))?; + self.blank_line()?; + } + for (relative_index, option) in options[start..end].iter().enumerate() { + let index = start + relative_index; + let cursor_marker = if index == cursor { "›" } else { " " }; + let selected_marker = if selected[index] { "[x]" } else { "[ ]" }; + let option = self.truncate_option(option, 8); + let line = format!("{selected_marker} {option}"); + let line = if index == cursor { + color_stdout(&line, "1;32") + } else { + line + }; + self.write_line(&format!(" {cursor_marker} {line}"))?; + } + self.blank_line()?; + self.write_line( + "↑/↓ move • PgUp/PgDn jump • Space toggle • a toggle all • Enter confirm • q cancel", + )?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn render_text_prompt( + &mut self, + label: &str, + body: &[String], + input: &str, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + for line in body { + self.write_line(line)?; + } + if !body.is_empty() { + self.blank_line()?; + } + self.write_line(&format!("> {input}"))?; + self.blank_line()?; + self.write_line("Type text • Enter confirm • Esc cancel")?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn option_window(&self, option_count: usize, cursor: usize) -> (usize, usize, bool) { + if option_count == 0 { + return (0, 0, false); + } + let visible = self + .visible_option_rows(option_count) + .min(option_count) + .max(1); + if visible >= option_count { + return (0, option_count, false); + } + let half = visible / 2; + let mut start = cursor.saturating_sub(half); + if start + visible > option_count { + start = option_count - visible; + } + (start, start + visible, true) + } + + fn visible_option_rows(&self, option_count: usize) -> usize { + self.max_option_rows + .unwrap_or(option_count) + .min(option_count) + .max(1) + } + + fn truncate_option(&self, option: &str, prefix_width: usize) -> String { + let Some(cols) = self.terminal_cols else { + return option.to_owned(); + }; + let width = cols.saturating_sub(prefix_width).max(1); + truncate_chars(option, width) + } + + fn clear_if_raw(&mut self) -> Result<(), String> { + if self.raw_mode { + self.clear()?; + } + Ok(()) + } + + fn clear(&mut self) -> Result<(), String> { + write!(self.writer, "\x1b[2J\x1b[H").map_err(|error| error.to_string()) + } + + fn write_line(&mut self, text: &str) -> Result<(), String> { + write!(self.writer, "{}{}", text, terminal_line_end(self.raw_mode)) + .map_err(|error| error.to_string()) + } + + fn blank_line(&mut self) -> Result<(), String> { + write!(self.writer, "{}", terminal_line_end(self.raw_mode)) + .map_err(|error| error.to_string()) + } + + fn read_key(&mut self) -> Result { + if self.raw_mode { + return read_terminal_key(); + } + read_scripted_key(self.reader) + } +} + +fn terminal_line_end(raw_mode: bool) -> &'static str { + if raw_mode { "\r\n" } else { "\n" } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_owned(); + } + if max_chars <= 1 { + return "…".to_owned(); + } + value.chars().take(max_chars - 1).chain(['…']).collect() +} + +impl Drop for TerminalUi<'_, R, W> { + fn drop(&mut self) { + if self.raw_mode { + let _ = write!(self.writer, "\x1b[?25h"); + let _ = self.writer.flush(); + let _ = disable_raw_mode(); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TuiKey { + Up, + Down, + PageUp, + PageDown, + Home, + End, + Enter, + Space, + Backspace, + Esc, + Char(char), + Other, +} + +fn read_terminal_key() -> Result { + loop { + let event = crossterm::event::read().map_err(|error| error.to_string())?; + let crossterm::event::Event::Key(key) = event else { + continue; + }; + use crossterm::event::KeyCode; + return Ok(match key.code { + KeyCode::Up => TuiKey::Up, + KeyCode::Down => TuiKey::Down, + KeyCode::PageUp => TuiKey::PageUp, + KeyCode::PageDown => TuiKey::PageDown, + KeyCode::Home => TuiKey::Home, + KeyCode::End => TuiKey::End, + KeyCode::Enter => TuiKey::Enter, + KeyCode::Char(' ') => TuiKey::Space, + KeyCode::Char(ch) => TuiKey::Char(ch), + KeyCode::Backspace => TuiKey::Backspace, + KeyCode::Esc => TuiKey::Esc, + _ => TuiKey::Other, + }); + } +} + +fn read_scripted_key(reader: &mut R) -> Result { + let mut byte = [0_u8; 1]; + let count = reader.read(&mut byte).map_err(|error| error.to_string())?; + if count == 0 { + return Err("interactive input ended before a selection was made".to_owned()); + } + match byte[0] { + b'\n' | b'\r' => Ok(TuiKey::Enter), + b' ' => Ok(TuiKey::Space), + 8 | 127 => Ok(TuiKey::Backspace), + 27 => read_escape_sequence(reader), + byte if byte.is_ascii() && !byte.is_ascii_control() => Ok(TuiKey::Char(byte as char)), + _ => Ok(TuiKey::Other), + } +} + +fn read_escape_sequence(reader: &mut R) -> Result { + let mut rest = [0_u8; 2]; + let count = reader.read(&mut rest).map_err(|error| error.to_string())?; + if count < 2 || rest[0] != b'[' { + return Ok(TuiKey::Esc); + } + Ok(match rest[1] { + b'A' => TuiKey::Up, + b'B' => TuiKey::Down, + _ => TuiKey::Other, + }) +} + +#[derive(Clone)] +struct ScopedTranslationReport { + target: String, + overlay_id: String, + overlay_file: String, + overlay_path: PathBuf, + report: TranslationCoverageReport, + context: TranslationContextView, +} + +fn collect_translation_reports( + args: &TranslationArgs, +) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + + let mut reports = Vec::new(); + for target in &target_names { + let plan = plan_manifest_target( + &args.manifest_path, + target, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + let mut current = plan.base.clone(); + for (planned, overlay) in &plan.overlays { + if overlay.translations.is_some() { + if overlay_matches_scope(target, planned, overlay, args) { + let report = current + .translation_coverage(overlay) + .map_err(|error| format!("failed to resolve translation source fields for target {target}: {error}"))?; + let full_context = current.translation_context(&report).map_err(|error| { + format!("failed to build translation context for target {target}: {error}") + })?; + let mut entries = report + .entries + .iter() + .filter(|entry| entry_matches_scope(entry, args)) + .cloned() + .collect::>(); + if args.duplicates { + retain_duplicate_source_entries(&mut entries); + } + let scoped_report = TranslationCoverageReport { + overlay_id: report.overlay_id, + entries, + }; + let context = filter_context_to_report(full_context, &scoped_report); + reports.push(ScopedTranslationReport { + target: target.clone(), + overlay_id: planned.id.clone(), + overlay_file: planned.display_file.clone(), + overlay_path: planned.file.clone(), + report: scoped_report, + context, + }); + } + current = compose_lenient_translation_overlay(¤t, overlay)?; + } else { + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|report| { + output::compose_error( + "translations", + json!({"target": target, "overlay": planned.id}), + &report, + ) + })?; + } + } + } + Ok(reports) +} + +fn selected_target_names(manifest: &FederatedDeckManifest, args: &TranslationArgs) -> Vec { + if args.all_targets || args.target.is_none() { + manifest.targets.keys().cloned().collect::>() + } else { + vec![args.target.clone().expect("target exists")] + } +} + +#[derive(Clone, Debug, Default)] +struct OverlayEdits { + direct: BTreeSet, + contextual: BTreeMap>, + no_change: BTreeSet, + ignore_paths: BTreeSet, +} + +impl OverlayEdits { + fn is_empty(&self) -> bool { + self.direct.is_empty() + && self.contextual.is_empty() + && self.no_change.is_empty() + && self.ignore_paths.is_empty() + } + + fn count(&self) -> usize { + self.direct.len() + + self.contextual.values().map(BTreeSet::len).sum::() + + self.no_change.len() + + self.ignore_paths.len() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct StaleResolution { + old_source: String, + new_source: String, + context: Option, + replacement: Option, +} + +fn resolve_stale_translations( + args: &TranslationArgs, + reports: &[ScopedTranslationReport], +) -> Result, String> { + let action = args + .resolve_action + .expect("resolve action checked before resolving stale translations"); + let mut resolutions_by_file = BTreeMap::>::new(); + let mut stale_record_matched_filter = false; + let mut non_stale_source_match = None::; + + for report in reports { + let shadowing_entries = report + .report + .entries + .iter() + .filter(|entry| entry_can_shadow_stale_translation(entry)) + .collect::>(); + for entry in &report.report.entries { + if !entry_matches_stale_resolution_filter(entry, args) { + if entry.category != TranslationCoverageCategory::StaleTranslation + && args + .new_source + .as_ref() + .is_some_and(|source| source == &entry.source) + && non_stale_source_match.is_none() + { + non_stale_source_match = Some(entry.source.clone()); + } + continue; + } + + if entry.category != TranslationCoverageCategory::StaleTranslation { + non_stale_source_match.get_or_insert_with(|| entry.source.clone()); + continue; + } + + stale_record_matched_filter = true; + let shadowed = shadowing_entries + .iter() + .any(|candidate| entry_shadows_stale_record(candidate, entry)); + if entry.path.starts_with("translations.stale_translations.") && !shadowed { + continue; + } + + let old_source = entry.old_source.clone().ok_or_else(|| { + format!( + "stale translation at {} is missing old_source; refusing to resolve", + entry.path + ) + })?; + resolutions_by_file + .entry(report.overlay_path.clone()) + .or_default() + .insert(StaleResolution { + old_source, + new_source: entry.source.clone(), + context: entry.context.clone(), + replacement: args.replacement_translation.clone(), + }); + } + } + + if resolutions_by_file.is_empty() { + if stale_record_matched_filter { + return Err(stale_resolution_mismatched_current_source_error(args)); + } + if let Some(source) = non_stale_source_match { + return Err(format!( + "source {source:?} is not stale; refusing to resolve" + )); + } + return Err(stale_resolution_no_match_error(args)); + } + + let planned = resolutions_by_file + .into_iter() + .map(|(path, resolutions)| plan_stale_resolutions(&path, action, &resolutions)) + .collect::, String>>()?; + commit_translation_documents(args, planned) +} + +fn entry_can_shadow_stale_translation(entry: &TranslationCoverageEntry) -> bool { + matches!( + entry.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + ) +} + +fn entry_shadows_stale_record( + candidate: &TranslationCoverageEntry, + stale: &TranslationCoverageEntry, +) -> bool { + candidate.source == stale.source + && match candidate.category { + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::NoChange => true, + TranslationCoverageCategory::ContextualTranslation => stale + .context + .as_deref() + .is_none_or(|context| context_matches_path(context, &candidate.path)), + _ => false, + } +} + +fn context_matches_path(context_path: &str, path: &str) -> bool { + path == context_path + || path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn entry_matches_stale_resolution_filter( + entry: &TranslationCoverageEntry, + args: &TranslationArgs, +) -> bool { + if let Some(old_source) = &args.old_source + && entry.old_source.as_deref() != Some(old_source.as_str()) + { + return false; + } + if let Some(new_source) = &args.new_source + && entry.source != *new_source + { + return false; + } + if let Some(context) = &args.stale_context + && entry.context.as_deref() != Some(context.as_str()) + { + return false; + } + true +} + +fn stale_resolution_mismatched_current_source_error(args: &TranslationArgs) -> String { + let mut parts = Vec::new(); + if let Some(old_source) = &args.old_source { + parts.push(format!("old_source={old_source:?}")); + } + if let Some(new_source) = &args.new_source { + parts.push(format!("new_source={new_source:?}")); + } + if let Some(context) = &args.stale_context { + parts.push(format!("context={context:?}")); + } + if parts.is_empty() { + "matched stale translation record does not match the current base text; refusing to resolve" + .to_owned() + } else { + format!( + "stale translation {} does not match the current base text; refusing to resolve", + parts.join(" ") + ) + } +} + +fn stale_resolution_no_match_error(args: &TranslationArgs) -> String { + if let Some(new_source) = &args.new_source { + format!("no stale translation matched new_source={new_source:?}") + } else { + "no stale translations matched the selected target and scope".to_owned() + } +} + +fn plan_stale_resolutions( + path: &Path, + action: StaleResolveAction, + resolutions: &BTreeSet, +) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut document = overlay_source_document(path, &input)?; + for resolution in resolutions { + let replacement = (action == StaleResolveAction::Replace).then(|| { + resolution + .replacement + .as_deref() + .expect("replace action requires replacement translation") + }); + document + .resolve_stale_translation( + &resolution.old_source, + &resolution.new_source, + resolution.context.as_deref(), + replacement, + ) + .map_err(|error| error.to_string())?; + } + Ok(PlannedTranslationDocument { + path: path.to_path_buf(), + input, + document, + count: resolutions.len(), + }) +} + +fn direct_stub_edits_from_reports( + reports: &[ScopedTranslationReport], + full: bool, +) -> BTreeMap { + let mut edits = BTreeMap::::new(); + for report in reports { + let stubs = report + .report + .entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && (full || default_report_includes_entry(entry)) + }) + .map(|entry| entry.source.clone()) + .collect::>(); + if !stubs.is_empty() { + edits + .entry(report.overlay_path.clone()) + .or_default() + .direct + .extend(stubs); + } + } + edits +} + +fn prompt_selective_apply( + reports: &[ScopedTranslationReport], + reader: &mut R, + writer: &mut W, + raw_mode: bool, + full: bool, +) -> Result, String> { + let mut ui = TerminalUi::new(reader, writer, raw_mode)?; + let rows = reports + .iter() + .enumerate() + .flat_map(|(report_index, report)| { + report + .report + .entries + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.category.is_problem() + && (entry.category != TranslationCoverageCategory::UntranslatedFallback + || full + || default_report_includes_entry(entry)) + }) + .map(move |(entry_index, _)| (report_index, entry_index)) + }) + .collect::>(); + + if rows.is_empty() { + ui.message("No missing, stale, or invalid translation rows to apply.")?; + return Ok(BTreeMap::new()); + } + + let row_labels = rows + .iter() + .map(|(report_index, entry_index)| { + let report = &reports[*report_index]; + let entry = &report.report.entries[*entry_index]; + format!( + "{} {} {} source={} — {}", + report.target, + report.overlay_id, + entry.category.as_str(), + yaml_scalar(&entry.source), + entry.path + ) + }) + .collect::>(); + let selected = ui.select_many("Rows to edit", &row_labels, true)?; + + let selected_missing = selected + .iter() + .copied() + .filter(|row_index| { + let (_, entry_index) = rows[*row_index]; + reports[rows[*row_index].0].report.entries[entry_index].category + == TranslationCoverageCategory::UntranslatedFallback + }) + .collect::>(); + + let bulk_action = if selected_missing.is_empty() { + MissingApplyAction::Skip + } else { + let labels = vec![ + format!( + "mark no-change for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add direct source→source translation stubs for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add contextual source→source translation stubs for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add ignore_paths entries for all {} selected missing translations", + selected_missing.len() + ), + "decide per row".to_owned(), + "skip selected missing translations".to_owned(), + ]; + match ui.select_one("Action for selected missing translations", &labels, 0)? { + 0 => MissingApplyAction::NoChange, + 1 => MissingApplyAction::Direct, + 2 => MissingApplyAction::Contextual, + 3 => MissingApplyAction::IgnorePath, + 4 => MissingApplyAction::DecidePerRow, + 5 => MissingApplyAction::Skip, + _ => unreachable!(), + } + }; + + let mut edits = BTreeMap::::new(); + for row_index in selected { + let (report_index, entry_index) = rows[row_index]; + let report = &reports[report_index]; + let entry = &report.report.entries[entry_index]; + match entry.category { + TranslationCoverageCategory::UntranslatedFallback => { + let action = match bulk_action { + MissingApplyAction::DecidePerRow => { + prompt_missing_apply_action(&mut ui, entry)? + } + other => other, + }; + apply_missing_translation_action(&mut edits, &report.overlay_path, entry, action); + } + _ => { + ui.message(&format!( + "{} at {} is stale/invalid; no safe automatic rewrite is applied, skipping. Use `brainbrew translations --resolve confirm` when the existing translation is still correct, or `brainbrew translations --resolve replace --translation ` to retire the stale record with a new translation.", + entry.category.as_str(), + entry.path + ))?; + } + } + } + + edits.retain(|_, edits| !edits.is_empty()); + if edits.is_empty() { + return Ok(edits); + } + + let confirm_labels = vec!["apply selected changes".to_owned(), "cancel".to_owned()]; + if ui.select_one("Apply selected changes?", &confirm_labels, 0)? == 0 { + Ok(edits) + } else { + Ok(BTreeMap::new()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MissingApplyAction { + NoChange, + Direct, + Contextual, + IgnorePath, + DecidePerRow, + Skip, +} + +fn prompt_missing_apply_action( + ui: &mut TerminalUi<'_, R, W>, + entry: &TranslationCoverageEntry, +) -> Result { + let context = contextual_context_for_entry(entry); + let action_labels = vec![ + "mark no-change".to_owned(), + "add direct source→source translation stub".to_owned(), + format!("add contextual source→source translation stub at {context}"), + "add ignore path for this deck path".to_owned(), + "skip".to_owned(), + ]; + match ui.select_one( + &format!( + "{} at {} source={}", + entry.category.as_str(), + entry.path, + yaml_scalar(&entry.source) + ), + &action_labels, + 0, + )? { + 0 => Ok(MissingApplyAction::NoChange), + 1 => Ok(MissingApplyAction::Direct), + 2 => Ok(MissingApplyAction::Contextual), + 3 => Ok(MissingApplyAction::IgnorePath), + 4 => Ok(MissingApplyAction::Skip), + _ => unreachable!(), + } +} + +fn apply_missing_translation_action( + edits: &mut BTreeMap, + overlay_path: &Path, + entry: &TranslationCoverageEntry, + action: MissingApplyAction, +) { + let file_edits = edits.entry(overlay_path.to_path_buf()).or_default(); + match action { + MissingApplyAction::NoChange => { + file_edits.no_change.insert(entry.source.clone()); + } + MissingApplyAction::Direct => { + file_edits.direct.insert(entry.source.clone()); + } + MissingApplyAction::Contextual => { + file_edits + .contextual + .entry(contextual_context_for_entry(entry)) + .or_default() + .insert(entry.source.clone()); + } + MissingApplyAction::IgnorePath => { + file_edits.ignore_paths.insert(entry.path.clone()); + } + MissingApplyAction::DecidePerRow | MissingApplyAction::Skip => {} + } +} + +fn contextual_context_for_entry(entry: &TranslationCoverageEntry) -> String { + context_parent_candidate(&entry.path).unwrap_or_else(|| entry.path.clone()) +} + +fn overlay_matches_scope( + target: &str, + planned: &PlannedOverlay, + overlay: &Overlay, + args: &TranslationArgs, +) -> bool { + if let Some(overlay_filter) = &args.overlay { + let overlay_id = overlay.id.as_str(); + if !planned.id.contains(overlay_filter) + && !planned.display_file.contains(overlay_filter) + && !overlay_id.contains(overlay_filter) + { + return false; + } + } + if let Some(language) = &args.language + && !language_matches(language, target, planned, overlay) + { + return false; + } + true +} + +fn language_matches( + language: &str, + target: &str, + planned: &PlannedOverlay, + overlay: &Overlay, +) -> bool { + let language = language.trim(); + if language.is_empty() { + return true; + } + let target_has_language = target == language + || target.starts_with(&format!("{language}-")) + || target.ends_with(&format!("-{language}")); + let overlay_id = overlay.id.as_str(); + let file_stem = Path::new(&planned.display_file) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + target_has_language + || planned.id.contains(&format!(".{language}")) + || planned.id.contains(&format!("-{language}")) + || overlay_id.contains(&format!(".{language}")) + || overlay_id.contains(&format!("-{language}")) + || file_stem == language + || planned + .display_file + .split(['/', '\\']) + .any(|part| part == language || part == format!("{language}.yaml")) +} + +fn filter_context_to_report( + mut context: TranslationContextView, + report: &TranslationCoverageReport, +) -> TranslationContextView { + context.units.retain(|unit| { + report.entries.iter().any(|entry| { + entry.path == unit.path + && entry.category == unit.category + && entry.source == unit.source + && entry.context.as_deref() == unit.context.as_deref() + }) + }); + context +} + +fn retain_duplicate_source_entries(entries: &mut Vec) { + let mut counts = BTreeMap::::new(); + for entry in entries.iter().filter(|entry| !entry.source.is_empty()) { + *counts.entry(entry.source.clone()).or_insert(0) += 1; + } + entries.retain(|entry| counts.get(&entry.source).copied().unwrap_or(0) > 1); +} + +fn entry_matches_scope(entry: &TranslationCoverageEntry, args: &TranslationArgs) -> bool { + if let Some(source) = &args.source + && !entry.source.contains(source) + { + return false; + } + if let Some(status) = &args.status + && !status_matches_filter(entry.category, status) + { + return false; + } + if let Some(note) = &args.note { + let full = if note.starts_with("note.") { + note.clone() + } else { + format!("note.{note}") + }; + if !entry.path.starts_with(&format!("notes.{note}.")) + && !entry.path.starts_with(&format!("notes.{full}.")) + && !entry.path.contains(&format!(".notes.{note}.")) + && !entry.path.contains(&format!(".notes.{full}.")) + { + return false; + } + } + if let Some(field) = &args.field { + let full = if field.starts_with("field.") { + field.clone() + } else { + format!("field.{field}") + }; + if !entry.path.contains(&format!(".fields.{field}")) + && !entry.path.contains(&format!(".fields.{full}")) + { + return false; + } + } + if !args.path_prefixes.is_empty() + && !args + .path_prefixes + .iter() + .any(|prefix| entry.path == *prefix || entry.path.starts_with(&format!("{prefix}."))) + { + return false; + } + true +} + +fn print_no_translation_reports(args: &TranslationArgs) -> Result<(), String> { + let non_dictionary = non_dictionary_translation_overlays(args)?; + if non_dictionary.is_empty() { + println!( + "{}", + color_stdout( + "No translation coverage entries matched the selected target and scope.", + "33" + ) + ); + println!( + "Try a broader scope, or run `brainbrew translations --manifest {} --all-targets`.", + args.manifest_path.display() + ); + return Ok(()); + } + + println!( + "{}", + color_stdout( + "No translation dictionary coverage reports matched the selected target and scope.", + "33" + ) + ); + println!(); + println!( + "The selected target includes translation overlays that do not use a `translations:` dictionary yet:" + ); + for overlay in non_dictionary { + println!(" - {} ({})", overlay.id, overlay.label); + } + println!(); + println!( + "`brainbrew translations` reports source-keyed translation dictionaries such as `translations.direct`, `translations.contextual`, plus path-scoped `target_adaptations`." + ); + println!( + "Patch-style translation overlays still compose, but they do not expose coverage data for this workflow yet." + ); + Ok(()) +} + +fn non_dictionary_translation_overlays( + args: &TranslationArgs, +) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + let mut choices = BTreeSet::::new(); + for target in target_names { + let plan = plan_manifest_target( + &args.manifest_path, + &target, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + for (planned, overlay) in &plan.overlays { + if overlay.kind == OverlayKind::Translation + && overlay.translations.is_none() + && overlay_matches_scope(&target, planned, overlay, args) + { + choices.insert(OverlayChoice { + id: planned.id.clone(), + label: planned.display_file.clone(), + }); + } + } + } + Ok(choices.into_iter().collect()) +} + +fn print_human_reports(reports: &[ScopedTranslationReport], full: bool) { + for report in reports { + let display_entries = display_entries(&report.report.entries, full); + let display_counts = category_counts_refs(&display_entries); + let all_counts = category_counts(&report.report.entries); + let hidden_untranslated = hidden_untranslated_fallback_count(&report.report.entries, full); + println!( + "{}", + color_stdout( + &format!( + "Translation coverage for target {} overlay {} ({})", + report.target, report.overlay_id, report.overlay_file + ), + "1;36" + ) + ); + println!( + " {}: {}", + color_stdout("direct translations", "32"), + all_counts.get("direct_translation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("contextual translations", "36"), + all_counts + .get("contextual_translation") + .copied() + .unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("target adaptations", "35"), + all_counts.get("target_adaptation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("intentionally unchanged text", "34"), + all_counts.get("no_change").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("missing text translations", "31"), + display_counts + .get("untranslated_fallback") + .copied() + .unwrap_or(0) + ); + if hidden_untranslated > 0 { + println!( + " {}: {}", + color_stdout("hidden structural/media/tag fallbacks", "2"), + hidden_untranslated + ); + } + println!( + " {}: {}", + color_stdout("ignored entries", "2"), + all_counts.get("ignored_source").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("stale translations", "33"), + all_counts.get("stale_translation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("stale/invalid keys", "33"), + report + .report + .entries + .iter() + .filter(|entry| is_stale_or_invalid(entry.category)) + .count() + ); + if hidden_untranslated > 0 && !full { + println!(" hint: use --full to include structural/media/tag fallbacks"); + } + + let problem_entries = display_entries + .iter() + .filter(|entry| entry.category.is_problem()) + .copied() + .collect::>(); + let shown_problem_limit = 40; + for entry in problem_entries.iter().take(shown_problem_limit) { + let old_source = entry + .old_source + .as_deref() + .map(|old_source| format!(" old_source={}", yaml_scalar(old_source))) + .unwrap_or_default(); + println!( + " - {} {}{} source={} translated={}", + color_category(entry.category, display_category_name(entry.category)), + entry.path, + old_source, + yaml_scalar(&entry.source), + entry + .translated + .as_deref() + .map(yaml_scalar) + .unwrap_or_else(|| "''".to_owned()) + ); + } + if problem_entries.len() > shown_problem_limit { + println!( + " … {} more problem entries not shown; narrow the scope or use --json", + problem_entries.len() - shown_problem_limit + ); + } + } +} + +fn display_entries( + entries: &[TranslationCoverageEntry], + full: bool, +) -> Vec<&TranslationCoverageEntry> { + entries + .iter() + .filter(|entry| full || default_report_includes_entry(entry)) + .collect() +} + +fn hidden_untranslated_fallback_count(entries: &[TranslationCoverageEntry], full: bool) -> usize { + if full { + return 0; + } + entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && !default_report_includes_entry(entry) + }) + .count() +} + +fn default_report_includes_entry(entry: &TranslationCoverageEntry) -> bool { + if entry.category != TranslationCoverageCategory::UntranslatedFallback { + return true; + } + default_report_includes_untranslated_fallback(entry) +} + +fn default_report_includes_untranslated_fallback(entry: &TranslationCoverageEntry) -> bool { + let path = entry.path.as_str(); + path.starts_with("notes.") + && path.contains(".fields.") + && !is_media_or_structural_field_path(path) + && !looks_like_media_markup(&entry.source) +} + +fn is_media_or_structural_field_path(path: &str) -> bool { + path.ends_with(".fields.field.flag") || path.ends_with(".fields.field.map") +} + +fn looks_like_media_markup(source: &str) -> bool { + let trimmed = source.trim_start(); + trimmed.starts_with(" &'static str { + match category { + TranslationCoverageCategory::UntranslatedFallback => "missing_translation", + other => other.as_str(), + } +} + +fn is_stale_or_invalid(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::InvalidTargetAdaptation + | TranslationCoverageCategory::StructuralFieldNameTranslation + ) +} + +fn validate_status_filter(status: &str) -> Result<(), String> { + if status_filter_is_known(status) { + Ok(()) + } else { + Err(format!( + "invalid translation status {status:?}; expected missing, stale, translated, changed, direct, contextual, no-change, adaptation, variable, adapter-id, ignored, or a coverage category name" + )) + } +} + +fn status_filter_is_known(status: &str) -> bool { + let normalized = normalized_status_filter(status); + matches!( + normalized.as_str(), + "missing" + | "untranslated" + | "stale" + | "invalid" + | "translated" + | "covered" + | "changed" + | "direct" + | "contextual" + | "no_change" + | "adaptation" + | "variable" + | "adapter_id" + | "ignored" + | "direct_translation" + | "contextual_translation" + | "target_adaptation" + | "target_deletion" + | "variable_translation" + | "adapter_id_translation" + | "ignored_source" + | "untranslated_fallback" + | "stale_direct_key" + | "stale_contextual_key" + | "stale_no_change_key" + | "stale_target_adaptation" + | "stale_variable_key" + | "stale_adapter_id_key" + | "stale_translation" + | "invalid_target_adaptation" + | "structural_field_name_translation" + ) +} + +fn status_matches_filter(category: TranslationCoverageCategory, status: &str) -> bool { + let normalized = normalized_status_filter(status); + match normalized.as_str() { + "missing" | "untranslated" => category == TranslationCoverageCategory::UntranslatedFallback, + "stale" | "invalid" => is_stale_or_invalid(category), + "translated" | "covered" => matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::TargetAdaptation + | TranslationCoverageCategory::TargetDeletion + | TranslationCoverageCategory::VariableTranslation + | TranslationCoverageCategory::AdapterIdTranslation + ), + "changed" => matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::TargetAdaptation + | TranslationCoverageCategory::TargetDeletion + | TranslationCoverageCategory::VariableTranslation + ), + "direct" => category == TranslationCoverageCategory::DirectTranslation, + "contextual" => category == TranslationCoverageCategory::ContextualTranslation, + "no_change" => category == TranslationCoverageCategory::NoChange, + "adaptation" => matches!( + category, + TranslationCoverageCategory::TargetAdaptation + | TranslationCoverageCategory::TargetDeletion + ), + "variable" => category == TranslationCoverageCategory::VariableTranslation, + "adapter_id" => category == TranslationCoverageCategory::AdapterIdTranslation, + "ignored" => category == TranslationCoverageCategory::IgnoredSource, + other => category.as_str() == other, + } +} + +fn normalized_status_filter(status: &str) -> String { + status.trim().to_ascii_lowercase().replace(['-', ' '], "_") +} + +/// Versioned envelope shared by every successful `translations --json` mode. +/// +/// Keep this intentionally small: mode-specific objects remain additive while +/// scripts can validate the schema version and result kind before reading them. +fn print_translation_json(kind: &str, payload: serde_json::Value) { + let mut value = payload; + let object = value + .as_object_mut() + .expect("translation JSON payload is always an object"); + object.insert( + "schema_version".to_owned(), + json!(TRANSLATION_JSON_SCHEMA_VERSION), + ); + object.insert("kind".to_owned(), json!(kind)); + println!( + "{}", + serde_json::to_string_pretty(&value).expect("translation JSON serializes") + ); +} + +fn print_json_reports(reports: &[ScopedTranslationReport], applied: &BTreeMap) { + let reports_json = reports + .iter() + .map(|report| { + json!({ + "target": report.target, + "overlay": report.overlay_id, + "file": report.overlay_file, + "summary": category_counts(&report.report.entries), + "entries": report.report.entries.iter().map(entry_json).collect::>(), + }) + }) + .collect::>(); + print_translation_json( + "translation_report", + json!({ + "reports": reports_json, + "applied": applied, + }), + ); +} + +fn print_json_contexts(reports: &[ScopedTranslationReport]) { + let contexts_json = reports + .iter() + .map(|report| { + json!({ + "target": report.target, + "language": inferred_report_language(report), + "overlay": report.overlay_id, + "file": report.overlay_file, + "units": report.context.units.iter().map(context_unit_json).collect::>(), + }) + }) + .collect::>(); + print_translation_json("translation_context", json!({ "contexts": contexts_json })); +} + +fn context_unit_json(unit: &TranslationContextUnit) -> serde_json::Value { + json!({ + "status": unit.category.as_str(), + "path": unit.path, + "source": unit.source, + "old_source": unit.old_source, + "translated": unit.translated, + "context": unit.context, + "note_id": unit.note_id.as_ref().map(|id| id.as_str()), + "note_type_id": unit.note_type_id.as_ref().map(|id| id.as_str()), + "field_id": unit.field_id.as_ref().map(|id| id.as_str()), + "field_name": unit.field_name, + "source_occurrences": unit.source_occurrences, + "note_fields": unit.note_fields.iter().map(|field| json!({ + "field_id": field.field_id.as_str(), + "field_name": field.field_name, + "source": field.source, + "translated": field.translated, + "status": field.category.map(|category| category.as_str()), + })).collect::>(), + "message": unit.message.as_ref().map(|message| json!({ + "source": message.source, + "translated": message.translated, + "format": message.format.as_ref().map(|component| json!({ + "index": component.index, + "name": component.name, + "kind": component.kind.as_str(), + "path": component.path, + "source": component.source, + "translated": component.translated, + "reference": component.reference, + "status": component.category.map(|category| category.as_str()), + })), + "components": message.components.iter().map(|component| json!({ + "index": component.index, + "name": component.name, + "kind": component.kind.as_str(), + "path": component.path, + "source": component.source, + "translated": component.translated, + "reference": component.reference, + "status": component.category.map(|category| category.as_str()), + })).collect::>(), + })), + "card_templates": unit.card_templates.iter().map(|card| json!({ + "template_id": card.template_id.as_str(), + "template_name": card.template_name, + "sides": card.sides.iter().map(|side| side.as_str()).collect::>(), + "question_format": card.question_format, + "answer_format": card.answer_format, + })).collect::>(), + }) +} + +fn print_human_contexts(reports: &[ScopedTranslationReport], full: bool) { + for report in reports { + let language = inferred_report_language(report); + let units = report + .context + .units + .iter() + .filter(|unit| full || default_context_includes_unit(unit)) + .collect::>(); + println!( + "{}", + color_stdout( + &format!( + "Translation context for target {} language {} overlay {} ({})", + report.target, language, report.overlay_id, report.overlay_file + ), + "1;36" + ) + ); + if units.is_empty() { + println!(" no context units matched this scope"); + continue; + } + for unit in units { + print_context_unit(unit, &language); + } + } +} + +fn default_context_includes_unit(unit: &TranslationContextUnit) -> bool { + is_stale_or_invalid(unit.category) + || (unit.note_id.is_some() + && unit.field_id.is_some() + && !is_media_or_structural_field_path(&unit.path) + && !looks_like_media_markup(&unit.source)) +} + +fn print_context_unit(unit: &TranslationContextUnit, language: &str) { + println!( + " - {} {}", + color_category(unit.category, display_category_name(unit.category)), + unit.path + ); + if unit.source_occurrences > 1 { + println!( + " duplicate source group: {} occurrence(s) of {}", + unit.source_occurrences, + yaml_scalar(&unit.source) + ); + } + if let Some(note_id) = &unit.note_id { + println!(" note: {note_id}"); + } + if let Some(note_type_id) = &unit.note_type_id { + println!(" note type: {note_type_id}"); + } + if let Some(field_id) = &unit.field_id { + let field_name = unit + .field_name + .as_ref() + .map(|name| format!(" ({name})")) + .unwrap_or_default(); + println!(" field: {field_id}{field_name}"); + } + if !unit.note_fields.is_empty() { + print_note_field_context(unit, language); + } + if let Some(message) = &unit.message { + print_message_context(message, language); + } + if let Some(context) = &unit.context { + println!(" dictionary context: {context}"); + } + if !unit.card_templates.is_empty() { + let cards = unit + .card_templates + .iter() + .map(|card| { + let sides = card + .sides + .iter() + .map(|side| side.as_str()) + .collect::>() + .join("+"); + format!("{} [{}]", card.template_id, sides) + }) + .collect::>() + .join(", "); + println!(" cards: {cards}"); + } + for line in side_by_side_lines( + "source/en", + &unit.source, + &format!("target/{language}"), + unit.translated.as_deref().unwrap_or(""), + ) { + println!(" {line}"); + } +} + +fn print_note_field_context(unit: &TranslationContextUnit, language: &str) { + println!(" note fields (source/en | target/{language}):"); + for field in unit.note_fields.iter().filter(|field| { + !field.source.is_empty() + && !looks_like_media_markup(&field.source) + && !is_media_or_structural_field_id(field.field_id.as_str()) + }) { + let marker = if unit.field_id.as_ref() == Some(&field.field_id) { + "*" + } else { + " " + }; + println!( + " {marker} {:<24} {} | {}", + format!("{} ({})", field.field_id, field.field_name), + compact_context_value(&field.source), + compact_context_value(&field.translated) + ); + } +} + +fn print_message_context(message: &TranslationMessageContext, language: &str) { + println!(" structured message (source/en | target/{language}):"); + println!( + " resolved {} | {}", + compact_context_value(&message.source), + compact_context_value(&message.translated) + ); + if let Some(format) = &message.format { + println!( + " {:<32} {} | {}", + "format", + compact_context_value(&format.source), + compact_context_value(&format.translated) + ); + } + for component in &message.components { + let label_prefix = component + .name + .as_deref() + .map_or_else(|| format!("[{}]", component.index), ToOwned::to_owned); + let label = match component.reference.as_deref() { + Some(reference) => { + format!("{} {} {}", label_prefix, component.kind.as_str(), reference) + } + None => format!("{} {}", label_prefix, component.kind.as_str()), + }; + println!( + " {:<32} {} | {}", + label, + compact_context_value(&component.source), + compact_context_value(&component.translated) + ); + } +} + +fn is_media_or_structural_field_id(field_id: &str) -> bool { + field_id == "field.flag" || field_id == "field.map" +} + +fn compact_context_value(value: &str) -> String { + let collapsed = value.split_whitespace().collect::>().join(" "); + const LIMIT: usize = 42; + if collapsed.chars().count() <= LIMIT { + collapsed + } else { + let mut truncated = collapsed + .chars() + .take(LIMIT.saturating_sub(1)) + .collect::(); + truncated.push('…'); + truncated + } +} + +fn side_by_side_lines( + left_header: &str, + left: &str, + right_header: &str, + right: &str, +) -> Vec { + const COLUMN_WIDTH: usize = 38; + let mut lines = vec![format!( + "{left_header: Vec { + let mut lines = Vec::new(); + for raw_line in text.lines().flat_map(|line| line.split("\n")) { + let mut current = String::new(); + for word in raw_line.split_whitespace() { + let separator = usize::from(!current.is_empty()); + if current.chars().count() + separator + word.chars().count() > width + && !current.is_empty() + { + lines.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() { + lines.push(current); + } + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd)] +struct TranslationSummaryCounts { + direct_translation: usize, + contextual_translation: usize, + no_change: usize, + target_adaptation: usize, + target_deletion: usize, + variable_translation: usize, + adapter_id_translation: usize, + untranslated_fallback: usize, + missing_text_translation: usize, + hidden_untranslated_fallback: usize, + ignored_source: usize, + stale_invalid: usize, +} + +struct TranslationSummaryRow { + language: String, + targets: BTreeSet, + overlay_id: String, + overlay_file: String, + counts: TranslationSummaryCounts, +} + +fn print_json_summary(reports: &[ScopedTranslationReport]) { + let summaries = translation_summary_rows(reports) + .into_iter() + .map(|row| { + json!({ + "language": row.language, + "targets": row.targets.into_iter().collect::>(), + "overlay": row.overlay_id, + "file": row.overlay_file, + "direct_translation": row.counts.direct_translation, + "contextual_translation": row.counts.contextual_translation, + "no_change": row.counts.no_change, + "target_adaptation": row.counts.target_adaptation, + "target_deletion": row.counts.target_deletion, + "variable_translation": row.counts.variable_translation, + "adapter_id_translation": row.counts.adapter_id_translation, + "untranslated_fallback": row.counts.untranslated_fallback, + "missing_text_translation": row.counts.missing_text_translation, + "hidden_untranslated_fallback": row.counts.hidden_untranslated_fallback, + "ignored_source": row.counts.ignored_source, + "stale_invalid": row.counts.stale_invalid, + }) + }) + .collect::>(); + print_translation_json("translation_summary", json!({ "summaries": summaries })); +} + +fn print_human_summary(reports: &[ScopedTranslationReport], full: bool) { + let rows = translation_summary_rows(reports); + println!("{}", color_stdout("Translation coverage summary", "1;36")); + if full { + print_full_human_summary_table(rows); + } else { + print_compact_human_summary_table(rows); + println!("Legend: ctxt=contextual, same=no-change, miss=actionable missing text."); + println!(" hidden/raw=untranslated fallbacks; --summary --full shows overlay/file."); + } +} + +fn print_compact_human_summary_table(rows: Vec) { + let mut table = vec![vec![ + "lang".to_owned(), + "tgt".to_owned(), + "direct".to_owned(), + "ctxt".to_owned(), + "same".to_owned(), + "adapt".to_owned(), + "delete".to_owned(), + "vars".to_owned(), + "ids".to_owned(), + "miss".to_owned(), + "hidden".to_owned(), + "raw".to_owned(), + "ign".to_owned(), + "stale".to_owned(), + ]]; + for row in rows { + table.push(vec![ + row.language, + row.targets.len().to_string(), + row.counts.direct_translation.to_string(), + row.counts.contextual_translation.to_string(), + row.counts.no_change.to_string(), + row.counts.target_adaptation.to_string(), + row.counts.variable_translation.to_string(), + row.counts.adapter_id_translation.to_string(), + row.counts.missing_text_translation.to_string(), + row.counts.hidden_untranslated_fallback.to_string(), + row.counts.untranslated_fallback.to_string(), + row.counts.ignored_source.to_string(), + row.counts.stale_invalid.to_string(), + ]); + } + let right_aligned = [ + false, true, true, true, true, true, true, true, true, true, true, true, true, true, + ]; + for line in aligned_table_lines(&table, &right_aligned) { + println!("{line}"); + } +} + +fn print_full_human_summary_table(rows: Vec) { + let mut table = vec![vec![ + "language".to_owned(), + "targets".to_owned(), + "overlay".to_owned(), + "file".to_owned(), + "direct".to_owned(), + "contextual".to_owned(), + "no-change".to_owned(), + "adaptations".to_owned(), + "deletions".to_owned(), + "variables".to_owned(), + "adapter-ids".to_owned(), + "missing-text".to_owned(), + "hidden-fallbacks".to_owned(), + "raw-untranslated".to_owned(), + "ignored".to_owned(), + "stale/invalid".to_owned(), + ]]; + for row in rows { + table.push(vec![ + row.language, + row.targets.len().to_string(), + row.overlay_id, + row.overlay_file, + row.counts.direct_translation.to_string(), + row.counts.contextual_translation.to_string(), + row.counts.no_change.to_string(), + row.counts.target_adaptation.to_string(), + row.counts.variable_translation.to_string(), + row.counts.adapter_id_translation.to_string(), + row.counts.missing_text_translation.to_string(), + row.counts.hidden_untranslated_fallback.to_string(), + row.counts.untranslated_fallback.to_string(), + row.counts.ignored_source.to_string(), + row.counts.stale_invalid.to_string(), + ]); + } + let right_aligned = [ + false, true, false, false, true, true, true, true, true, true, true, true, true, true, + true, true, + ]; + for line in aligned_table_lines(&table, &right_aligned) { + println!("{line}"); + } +} + +fn aligned_table_lines(rows: &[Vec], right_aligned: &[bool]) -> Vec { + let column_count = rows.iter().map(Vec::len).max().unwrap_or(0); + let mut widths = vec![0; column_count]; + for row in rows { + for (index, cell) in row.iter().enumerate() { + widths[index] = widths[index].max(cell.chars().count()); + } + } + rows.iter() + .map(|row| { + let mut line = String::new(); + for (index, width) in widths.iter().enumerate() { + if index > 0 { + line.push_str(" "); + } + let cell = row.get(index).map(String::as_str).unwrap_or_default(); + let padding = width.saturating_sub(cell.chars().count()); + if right_aligned.get(index).copied().unwrap_or(false) { + line.push_str(&" ".repeat(padding)); + line.push_str(cell); + } else { + line.push_str(cell); + line.push_str(&" ".repeat(padding)); + } + } + line.trim_end().to_owned() + }) + .collect() +} + +fn translation_summary_rows(reports: &[ScopedTranslationReport]) -> Vec { + let mut grouped = + BTreeMap::<(String, String, String, TranslationSummaryCounts), BTreeSet>::new(); + for report in reports { + let language = inferred_report_language(report); + let counts = translation_summary_counts(&report.report.entries); + grouped + .entry(( + language, + report.overlay_id.clone(), + report.overlay_file.clone(), + counts, + )) + .or_default() + .insert(report.target.clone()); + } + grouped + .into_iter() + .map( + |((language, overlay_id, overlay_file, counts), targets)| TranslationSummaryRow { + language, + targets, + overlay_id, + overlay_file, + counts, + }, + ) + .collect() +} + +fn translation_summary_counts(entries: &[TranslationCoverageEntry]) -> TranslationSummaryCounts { + let counts = category_counts(entries); + let untranslated_fallback = counts.get("untranslated_fallback").copied().unwrap_or(0); + let missing_text_translation = entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && default_report_includes_untranslated_fallback(entry) + }) + .count(); + TranslationSummaryCounts { + direct_translation: counts.get("direct_translation").copied().unwrap_or(0), + contextual_translation: counts.get("contextual_translation").copied().unwrap_or(0), + no_change: counts.get("no_change").copied().unwrap_or(0), + target_adaptation: counts.get("target_adaptation").copied().unwrap_or(0), + target_deletion: counts.get("target_deletion").copied().unwrap_or(0), + variable_translation: counts.get("variable_translation").copied().unwrap_or(0), + adapter_id_translation: counts.get("adapter_id_translation").copied().unwrap_or(0), + untranslated_fallback, + missing_text_translation, + hidden_untranslated_fallback: untranslated_fallback + .saturating_sub(missing_text_translation), + ignored_source: counts.get("ignored_source").copied().unwrap_or(0), + stale_invalid: entries + .iter() + .filter(|entry| is_stale_or_invalid(entry.category)) + .count(), + } +} + +fn inferred_report_language(report: &ScopedTranslationReport) -> String { + let stem = Path::new(&report.overlay_file) + .file_stem() + .and_then(|stem| stem.to_str()); + if let Some(language) = language_candidate(stem) { + return language; + } + if let Some(language) = stem + .and_then(|stem| stem.strip_prefix("translation-")) + .and_then(|candidate| language_candidate(Some(candidate))) + { + return language; + } + if let Some((_, suffix)) = report.overlay_id.rsplit_once(".translation.") { + if let Some(language) = language_candidate(Some(suffix)) { + return language; + } + if let Some(language) = suffix + .rsplit_once('.') + .and_then(|(_, last_segment)| language_candidate(Some(last_segment))) + { + return language; + } + } + if let Some(language) = language_candidate(Some(trim_target_language_suffixes(&report.target))) + { + return language; + } + if let Some(language) = stem + .and_then(|stem| stem.rsplit_once(['-', '.'])) + .and_then(|(_, suffix)| language_candidate(Some(suffix))) + { + return language; + } + "unknown".to_owned() +} + +fn trim_target_language_suffixes(target: &str) -> &str { + let mut candidate = target; + loop { + let Some((prefix, suffix)) = candidate.rsplit_once('-') else { + return candidate; + }; + if matches!( + suffix, + "standard" | "extended" | "experimental" | "hardcore" | "release" + ) { + candidate = prefix; + } else { + return candidate; + } + } +} + +fn entry_json(entry: &TranslationCoverageEntry) -> serde_json::Value { + json!({ + "category": entry.category.as_str(), + "path": entry.path, + "source": entry.source, + "old_source": entry.old_source, + "translated": entry.translated, + "context": entry.context, + }) +} + +fn category_counts(entries: &[TranslationCoverageEntry]) -> BTreeMap<&'static str, usize> { + let mut counts = BTreeMap::new(); + for entry in entries { + *counts.entry(entry.category.as_str()).or_insert(0) += 1; + } + counts +} + +fn category_counts_refs(entries: &[&TranslationCoverageEntry]) -> BTreeMap<&'static str, usize> { + let mut counts = BTreeMap::new(); + for entry in entries { + *counts.entry(entry.category.as_str()).or_insert(0) += 1; + } + counts +} + +struct PlannedTranslationDocument { + path: PathBuf, + input: String, + document: OverlaySourceDocument, + count: usize, +} + +fn plan_overlay_edits( + path: &Path, + edits: &OverlayEdits, +) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut document = overlay_source_document(path, &input)?; + document + .add_translation_stubs(TranslationStubs { + direct: edits.direct.clone(), + contextual: edits.contextual.clone(), + no_change: edits.no_change.clone(), + ignore_paths: edits.ignore_paths.clone(), + }) + .map_err(|error| error.to_string())?; + Ok(PlannedTranslationDocument { + path: path.to_path_buf(), + input, + document, + count: edits.count(), + }) +} + +fn commit_translation_documents( + args: &TranslationArgs, + planned: Vec, +) -> Result, String> { + let mut outputs = Vec::new(); + let mut updated_overlays = BTreeMap::new(); + let mut applied = BTreeMap::new(); + + for planned_document in planned { + if planned_document.count == 0 { + continue; + } + let emission = planned_document + .document + .emit() + .map_err(|error| error.to_string())?; + if !emission.included().is_empty() { + return Err(format!( + "{}: translation dictionary operation unexpectedly edited an included source", + planned_document.path.display() + )); + } + let output = emission.root().text().to_owned(); + let canonical_path = fs::canonicalize(&planned_document.path) + .map_err(|error| format!("{}: {error}", planned_document.path.display()))?; + let overlay = overlay_from_source_text(&planned_document.path, &output)?; + updated_overlays.insert(canonical_path, overlay); + if output != planned_document.input { + let validation_path = planned_document.path.clone(); + outputs.push(PlannedWorkspaceFile::validated( + &planned_document.path, + planned_document.input.into_bytes(), + output.into_bytes(), + move |bytes| { + let source = std::str::from_utf8(bytes).map_err(|error| { + format!( + "{}: replacement is not UTF-8: {error}", + validation_path.display() + ) + })?; + overlay_source_document(&validation_path, source).and_then(|document| { + document + .emit() + .map(|_| ()) + .map_err(|error| error.to_string()) + }) + }, + )?); + } + applied.insert( + planned_document.path.display().to_string(), + planned_document.count, + ); + } + + validate_final_translation_composition(args, &updated_overlays)?; + commit_workspace_files(&manifest_root(&args.manifest_path), outputs)?; + Ok(applied) +} + +fn validate_final_translation_composition( + args: &TranslationArgs, + updated_overlays: &BTreeMap, +) -> Result<(), String> { + if updated_overlays.is_empty() { + return Ok(()); + } + let manifest = read_manifest(&args.manifest_path)?; + for target in selected_target_names(&manifest, args) { + let plan = plan_manifest_target( + &args.manifest_path, + &target, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + let mut current = plan.base.clone(); + for (planned, original_overlay) in &plan.overlays { + let canonical_path = fs::canonicalize(&planned.file) + .map_err(|error| format!("{}: {error}", planned.file.display()))?; + let overlay = updated_overlays + .get(&canonical_path) + .unwrap_or(original_overlay); + if overlay.translations.is_some() { + current = compose_lenient_translation_overlay(¤t, overlay)?; + } else { + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|report| { + output::compose_error( + "translations", + json!({"target": target, "overlay": planned.id}), + &report, + ) + })?; + } + } + } + Ok(()) +} + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] +struct OverlayChoice { + id: String, + label: String, +} + +fn translation_overlay_choices(args: &TranslationArgs) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + let mut choices = BTreeSet::::new(); + for target in target_names { + let plan = plan_manifest_target( + &args.manifest_path, + &target, + &args.include_paths, + &args.package_roots, + &args.discovery_policy, + )?; + for (planned, overlay) in &plan.overlays { + if overlay.kind == OverlayKind::Translation + && args + .language + .as_ref() + .is_none_or(|language| language_matches(language, &target, planned, overlay)) + { + let suffix = if overlay.translations.is_some() { + "" + } else { + " (no translations: dictionary)" + }; + choices.insert(OverlayChoice { + id: planned.id.clone(), + label: format!("{} {}{}", planned.id, planned.display_file, suffix), + }); + } + } + } + Ok(choices.into_iter().collect()) +} + +fn inferred_languages(manifest: &FederatedDeckManifest) -> Vec { + let mut languages = BTreeSet::new(); + for (id, overlay) in &manifest.overlays { + let is_translation_overlay = overlay.kind.as_deref() == Some("translation") + || id.contains("translation.") + || id.contains("translation-"); + if !is_translation_overlay { + continue; + } + maybe_insert_language(id.rsplit(['.', '-']).next(), &mut languages); + let stem = Path::new(&overlay.file) + .file_stem() + .and_then(|stem| stem.to_str()); + if let Some(stem) = stem { + if let Some((_, suffix)) = stem.rsplit_once(['-', '.']) { + maybe_insert_language(Some(suffix), &mut languages); + } else { + maybe_insert_language(Some(stem), &mut languages); + } + } + } + languages.into_iter().collect() +} + +fn maybe_insert_language(candidate: Option<&str>, languages: &mut BTreeSet) { + if let Some(language) = language_candidate(candidate) { + languages.insert(language); + } +} + +fn language_candidate(candidate: Option<&str>) -> Option { + let language = candidate?; + if (2..=8).contains(&language.len()) + && language + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch == '-') + && language != "translation" + { + Some(language.to_owned()) + } else { + None + } +} + +fn note_id_from_path(path: &str) -> Option { + let rest = path.strip_prefix("notes.")?; + let end = [".fields.", ".tags.", ".variables.", ".adapter_ids."] + .into_iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + Some(rest[..end].to_owned()) +} + +fn field_id_from_path(path: &str) -> Option { + let rest = path.split_once(".fields.")?.1; + let end = [".variables.", ".adapter_ids."] + .into_iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + Some(rest[..end].to_owned()) +} + +fn context_parent_candidate(path: &str) -> Option { + if let Some((note_context, _)) = path.split_once(".fields.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_context, _)) = path.split_once(".tags.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_context, _)) = path.split_once(".variables.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_type_context, _)) = path.split_once(".fields.") + && note_type_context.starts_with("note_types.") + { + return Some(note_type_context.to_owned()); + } + if let Some((note_type_context, _)) = path.split_once(".card_templates.") + && note_type_context.starts_with("note_types.") + { + return Some(note_type_context.to_owned()); + } + None +} + +fn discover_nearby_manifests(policy: &DiscoveryPolicy) -> Result, String> { + let current = env::current_dir().map_err(|error| { + format!("package discovery failed closed: could not read current directory: {error}") + })?; + let mut results = + ManifestRegistry::discover_manifest_paths(std::slice::from_ref(¤t), policy)? + .into_iter() + .map(|path| relative_to_current(&path)) + .collect::>(); + for directory in current.ancestors().skip(1).take(4) { + let candidate = directory.join("brainbrew.yaml"); + match fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => { + results.insert(relative_to_current(&candidate)); + } + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "package discovery rejected symlink {} while checking nearby manifests", + candidate.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "package discovery failed closed while inspecting nearby manifest {}: {error}", + candidate.display() + )); + } + } + } + Ok(results.into_iter().take(20).collect()) +} + +fn relative_to_current(path: &Path) -> PathBuf { + env::current_dir() + .ok() + .and_then(|current| path.strip_prefix(current).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| path.to_path_buf()) +} + +fn missing_manifest_error(path: &Path, policy: &DiscoveryPolicy) -> Result { + let mut message = format!("No Brain Brew manifest found at {}.", path.display()); + if path == Path::new("brainbrew.yaml") { + message.push_str("\n\nRun from a deck workspace, or pass --manifest ."); + } + let manifests = discover_nearby_manifests(policy)?; + if !manifests.is_empty() { + message.push_str("\n\nFound possible manifests:"); + for manifest in &manifests { + message.push_str(&format!("\n - {}", manifest.display())); + } + if let Some(first) = manifests.first() { + message.push_str("\n\nTry:"); + message.push_str(&format!( + "\n brainbrew translations --manifest {} --target ", + shell_word(&first.display().to_string()) + )); + message.push_str("\n brainbrew translations --interactive"); + } + } + Ok(message) +} + +fn equivalent_command(args: &TranslationArgs) -> String { + let mut parts = vec!["brainbrew".to_owned(), "translations".to_owned()]; + push_flag_value( + &mut parts, + "--manifest", + &args.manifest_path.display().to_string(), + ); + if args.all_targets { + parts.push("--all-targets".to_owned()); + } else if let Some(target) = &args.target { + push_flag_value(&mut parts, "--target", target); + } + if let Some(language) = &args.language { + push_flag_value(&mut parts, "--language", language); + } + if let Some(overlay) = &args.overlay { + push_flag_value(&mut parts, "--overlay", overlay); + } + if let Some(note) = &args.note { + push_flag_value(&mut parts, "--note", note); + } + if let Some(field) = &args.field { + push_flag_value(&mut parts, "--field", field); + } + for prefix in &args.path_prefixes { + push_flag_value(&mut parts, "--path-prefix", prefix); + } + if args.apply { + parts.push("--apply".to_owned()); + } + if args.full { + parts.push("--full".to_owned()); + } + parts.join(" ") +} + +fn push_flag_value(parts: &mut Vec, flag: &str, value: &str) { + parts.push(flag.to_owned()); + parts.push(shell_word(value)); +} + +fn shell_word(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '=')) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn color_category(category: TranslationCoverageCategory, text: &str) -> String { + match category { + TranslationCoverageCategory::DirectTranslation => color_stdout(text, "32"), + TranslationCoverageCategory::ContextualTranslation => color_stdout(text, "36"), + TranslationCoverageCategory::NoChange => color_stdout(text, "34"), + TranslationCoverageCategory::TargetAdaptation => color_stdout(text, "35"), + TranslationCoverageCategory::TargetDeletion => color_stdout(text, "31"), + TranslationCoverageCategory::VariableTranslation + | TranslationCoverageCategory::AdapterIdTranslation + | TranslationCoverageCategory::IgnoredSource => color_stdout(text, "2"), + TranslationCoverageCategory::UntranslatedFallback => color_stdout(text, "31"), + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::InvalidTargetAdaptation + | TranslationCoverageCategory::StructuralFieldNameTranslation => color_stdout(text, "33"), + } +} + +fn color_stdout(text: &str, code: &str) -> String { + if color_enabled(io::stdout().is_terminal()) { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } +} + +fn color_enabled(is_terminal: bool) -> bool { + match env::var("BRAINBREW_COLOR") { + Ok(value) if value == "always" => true, + Ok(value) if value == "never" => false, + _ => env::var_os("NO_COLOR").is_none() && is_terminal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_mode_uses_crlf_line_endings() { + assert_eq!(terminal_line_end(false), "\n"); + assert_eq!(terminal_line_end(true), "\r\n"); + } + + #[test] + fn raw_mode_rendering_returns_cursor_to_column_zero() { + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: None, + terminal_cols: None, + }; + ui.render_select_one("Title", &["one".to_owned(), "two".to_owned()], 0) + .unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Title\r\n\r\n")); + assert!(output.contains(" › one\r\n")); + assert!(output.contains(" two\r\n")); + } + + #[test] + fn long_raw_mode_lists_scroll_to_keep_selection_visible() { + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: Some(3), + terminal_cols: Some(40), + }; + let options = (1..=10) + .map(|index| format!("option-{index}")) + .collect::>(); + ui.render_select_one("Title", &options, 8).unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Showing 8–10 of 10\r\n")); + assert!(!output.contains("option-1\r\n")); + assert!(output.contains(" › option-9\r\n")); + } + + #[test] + fn translation_json_documentation_matches_the_versioned_envelope() { + let documentation = + include_str!("../../../../documentation/docs/authoring/translations.md"); + assert!(documentation.contains(&format!( + "\"schema_version\": {TRANSLATION_JSON_SCHEMA_VERSION}" + ))); + for kind in [ + "translation_report", + "translation_context", + "translation_summary", + "stale_resolution", + ] { + assert!(documentation.contains(kind)); + } + } + + #[test] + fn long_options_are_truncated_to_prevent_wrapping() { + assert_eq!(truncate_chars("abcdef", 4), "abc…"); + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: None, + terminal_cols: Some(14), + }; + ui.render_select_one("Title", &["very-long-option".to_owned()], 0) + .unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(" › very-long…\r\n")); + } +} diff --git a/crates/brain-brew-cli/src/commands/validate.rs b/crates/brain-brew-cli/src/commands/validate.rs new file mode 100644 index 0000000..b9792df --- /dev/null +++ b/crates/brain-brew-cli/src/commands/validate.rs @@ -0,0 +1,181 @@ +use std::path::Path; + +use brain_brew_core::{ComposeReport, ValidationReport}; +use serde_json::{Value, json}; + +use crate::args::{parse_manifest_target_args, split_json_flag}; +use crate::help; +use crate::io::{read_and_compose_deck, read_deck_and_overlays}; +use crate::output; +use crate::planner::plan_manifest_target; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let (json_output, rest) = split_json_flag(args); + let manifest_args = parse_manifest_target_args(&rest)?; + let deck = if json_output { + let plan = plan_manifest_target( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + &manifest_args.discovery_policy, + )?; + let overlays = plan + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(); + match plan.base.compose(&overlays) { + Ok(deck) => deck, + Err(report) => return report_json_compose_failure(report, "invalid deck"), + } + } else { + plan_manifest_target( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + &manifest_args.discovery_policy, + )? + .compose() + .map_err(|report| { + output::compose_error( + "validate", + json!({ + "manifest": manifest_args.manifest_path, + "target": manifest_args.target, + }), + &report, + ) + })? + }; + let mut details = vec![ + ( + "manifest", + manifest_args.manifest_path.display().to_string(), + ), + ("target", manifest_args.target.clone()), + ]; + details.extend(output::deck_stats(&deck)); + return report_validation( + deck.validate(), + json_output, + format!("valid target {}", manifest_args.target), + details, + ); + } + + let mut json_output = false; + let mut deck_path = None; + let mut overlay_paths = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--json" => { + json_output = true; + index += 1; + } + "--overlay" => { + let Some(path) = args.get(index + 1) else { + return Err("--overlay requires a path".to_owned()); + }; + overlay_paths.push(path.clone()); + index += 2; + } + value if deck_path.is_none() => { + deck_path = Some(value.to_owned()); + index += 1; + } + other => return Err(format!("unexpected validate argument {other:?}")), + } + } + let Some(deck_path) = deck_path else { + return Err(help::usage_error( + "validate", + "usage: brainbrew validate [--overlay overlay.yaml ...] [--json]", + )); + }; + + let deck = if json_output { + let (base, overlays) = read_deck_and_overlays(Path::new(&deck_path), &overlay_paths)?; + let overlays = overlays + .into_iter() + .map(|(_, overlay)| overlay) + .collect::>(); + match base.compose(&overlays) { + Ok(deck) => deck, + Err(report) => return report_json_compose_failure(report, "invalid deck"), + } + } else { + read_and_compose_deck(Path::new(&deck_path), &overlay_paths)? + }; + let mut details = vec![("source", deck_path.clone())]; + if !overlay_paths.is_empty() { + details.push(("overlays", overlay_paths.len().to_string())); + } + details.extend(output::deck_stats(&deck)); + report_validation( + deck.validate(), + json_output, + "valid deck".to_owned(), + details, + ) +} + +fn report_validation( + result: Result<(), ValidationReport>, + json_output: bool, + success_message: String, + details: Vec<(&str, String)>, +) -> Result<(), String> { + match result { + Ok(()) => { + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({"status": "valid", "errors": []})) + .unwrap() + ); + } else { + output::print_success(success_message, &details); + } + Ok(()) + } + Err(report) => { + let diagnostics = report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect::>(); + if json_output { + report_json_diagnostics("invalid deck", diagnostics) + } else { + eprintln!("{}", output::render_diagnostics(&diagnostics)); + Err("invalid deck".to_owned()) + } + } + } +} + +fn report_json_compose_failure(report: ComposeReport, message: &str) -> Result<(), String> { + report_json_diagnostics( + message, + report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect(), + ) +} + +fn report_json_diagnostics( + message: &str, + diagnostics: Vec, +) -> Result<(), String> { + output::print_json_diagnostic_error("validate", Value::Null, message, &diagnostics); + Err(output::JSON_ERROR_ALREADY_PRINTED.to_owned()) +} diff --git a/crates/brain-brew-cli/src/commands/verify.rs b/crates/brain-brew-cli/src/commands/verify.rs new file mode 100644 index 0000000..0890367 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/verify.rs @@ -0,0 +1,590 @@ +use std::fs; +use std::path::Path; + +use brain_brew_core::{ + CanonicalDeck, Overlay, TranslationCoverageCategory, TranslationStackCoverageStatus, + validate_deck_content, +}; +use brain_brew_formats::{crowdanki, manifest, media_map, strict_yaml}; + +use crate::args::parse_verify_args; +use crate::help; +use crate::io::{ + SourceContext, include_roots_from_manifest, manifest_root, read_manifest, + resolve_include_target_for_context, top_level_media_include_path, verify_canonical_deck_format, + verify_manifest_format, verify_overlay_format, +}; +use crate::media_assets::verify_owned_media; +use crate::media_ownership::MediaRootSelections; +use crate::output; +use crate::path_authorization::PathAuthorizer; +use crate::planner::{ManifestRegistry, PlanSourceKind, TargetPlan}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("verify").expect("verify help exists")); + return Ok(()); + } + let verify_args = parse_verify_args(args)?; + if verify_args.media_mode == crate::media_verification::MediaVerificationMode::ReferenceOnly + && !verify_args.media_roots.is_empty() + { + return Err("--media-mode reference-only cannot be combined with --media-root because reference-only mode intentionally skips all media root and byte validation".to_owned()); + } + verify_manifest_format(&verify_args.manifest_path)?; + let manifest = read_manifest(&verify_args.manifest_path)?; + let root = manifest_root(&verify_args.manifest_path); + let registry = ManifestRegistry::load_with_policy( + &verify_args.manifest_path, + &verify_args.include_paths, + &verify_args.package_roots, + &verify_args.discovery_policy, + )?; + for loaded in registry.manifests() { + verify_manifest_format(&loaded.path)?; + } + let target_names = if verify_args.all_targets { + registry + .root() + .manifest + .targets + .keys() + .cloned() + .collect::>() + } else if let Some(target) = verify_args.target { + vec![target] + } else { + return Err(help::usage_error( + "verify", + "usage: brainbrew verify [--manifest brainbrew.yaml] --all-targets", + )); + }; + + let base_deck_path = PathAuthorizer::new("package", &root)? + .authorize_read(&verify_args.manifest_path, "base", &manifest.base) + .map_err(|error| error.to_string())? + .into_path_buf(); + verify_canonical_deck_format(&base_deck_path)?; + verify_included_media_map_format( + &base_deck_path, + &SourceContext { + root: root.clone(), + include_roots: include_roots_from_manifest( + &verify_args.manifest_path, + &root, + &manifest, + )?, + }, + )?; + let media_roots = MediaRootSelections::parse(®istry, &verify_args.media_roots, &root)?; + let mut warnings = Vec::new(); + let mut media_targets = Vec::new(); + for target in &target_names { + let target_reference = if verify_args.all_targets { + registry + .root() + .identity + .as_ref() + .map(|identity| format!("{}:{target}", identity.id)) + .unwrap_or_else(|| target.clone()) + } else { + target.clone() + }; + let plan = registry.plan(&target_reference)?; + verify_plan_source_formats(&plan)?; + let policy = verify_args.translation_coverage.unwrap_or_else(|| { + plan.target_manifest + .targets + .get(&plan.target) + .map(|target| target.translation_coverage) + .unwrap_or_default() + }); + verify_translation_coverage_policy(&plan, policy, &mut warnings)?; + let deck = plan.compose().map_err(|report| { + output::compose_error( + "verify", + serde_json::json!({"target": target, "manifest": verify_args.manifest_path}), + &report, + ) + })?; + deck.validate().map_err(|report| { + output::validation_error( + "verify", + serde_json::json!({"target": target, "manifest": verify_args.manifest_path}), + &report, + ) + })?; + let result = verify_owned_media(&plan, &deck, &media_roots, verify_args.media_mode, false)?; + warnings.extend( + result + .warnings + .into_iter() + .map(|warning| format!("target {target}: {warning}")), + ); + media_targets.push(serde_json::json!({ + "target": target, + "declarations": deck.media.len(), + "mode": verify_args.media_mode.name(), + "release_ready": verify_args.media_mode.release_ready(deck.media.len()), + })); + if !verify_args.skip_content_validation { + verify_deck_content(target, &deck)?; + } + verify_configured_exports( + &plan.target_manifest_root, + &plan.target_manifest, + &plan.target, + &deck, + )?; + } + + let release_ready = media_targets + .iter() + .all(|target| target["release_ready"].as_bool() == Some(true)); + if verify_args.json_output { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "verified", + "manifest": verify_args.manifest_path.display().to_string(), + "targets": target_names.len(), + "media": { + "mode": verify_args.media_mode.name(), + "release_ready": release_ready, + "targets": media_targets, + }, + "warnings": warnings, + })) + .expect("verify status JSON serializes") + ); + } else { + for warning in &warnings { + eprintln!("warning: {warning}"); + } + let suffix = if target_names.len() == 1 { "" } else { "s" }; + let mut details = vec![("manifest", verify_args.manifest_path.display().to_string())]; + details.push(( + "media verification", + if release_ready { + verify_args.media_mode.name().to_owned() + } else { + "reference_only (NOT RELEASE-READY)".to_owned() + }, + )); + if media_roots.supplied() { + details.push(("media roots", verify_args.media_roots.join(", "))); + } + output::print_success( + format!("verified {} target{suffix}", target_names.len()), + &details, + ); + } + Ok(()) +} + +fn verify_included_media_map_format( + deck_path: &Path, + context: &SourceContext, +) -> Result<(), String> { + let input = fs::read_to_string(deck_path) + .map_err(|error| format!("{}: {error}", deck_path.display()))?; + strict_yaml::reject_duplicate_keys(&input) + .map_err(|error| format!("{}: {error}", deck_path.display()))?; + let value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", deck_path.display()))?; + let Some(include_path) = top_level_media_include_path(&value) + .map_err(|error| format!("{}: {error}", deck_path.display()))? + else { + return Ok(()); + }; + let media_path = resolve_include_target_for_context(&include_path, context)?; + let input = fs::read_to_string(&media_path) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + media_map::from_str(&input).map_err(|error| format!("{}: {error}", media_path.display()))?; + let formatted = media_map::format_str(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + if formatted != input { + return Err(format!( + "{} is not in canonical format", + media_path.display() + )); + } + Ok(()) +} + +fn verify_deck_content(target: &str, deck: &CanonicalDeck) -> Result<(), String> { + let rendered = deck + .render_variables() + .map_err(|error| format!("content validation failed for target {target}: {error}"))?; + let report = validate_deck_content(&rendered); + if report.is_empty() { + Ok(()) + } else { + Err(format!( + "content validation failed for target {target}: {report}" + )) + } +} + +fn verify_plan_source_formats(plan: &TargetPlan) -> Result<(), String> { + verify_canonical_deck_format(&plan.base_source.path)?; + for source in plan.sources() { + match &source.kind { + PlanSourceKind::Overlay { .. } => verify_overlay_format(&source.path)?, + PlanSourceKind::MediaInclude => { + let input = fs::read_to_string(&source.path) + .map_err(|error| format!("{}: {error}", source.path.display()))?; + let formatted = media_map::format_str(&input) + .map_err(|error| format!("{}: {error}", source.path.display()))?; + if formatted != input { + return Err(format!( + "{} is not in canonical format", + source.path.display() + )); + } + } + PlanSourceKind::Base | PlanSourceKind::ScalarInclude { .. } => {} + } + } + Ok(()) +} + +fn verify_translation_coverage_policy( + plan: &TargetPlan, + policy: manifest::TranslationCoveragePolicy, + warnings: &mut Vec, +) -> Result<(), String> { + let overlays = plan + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(); + if !overlays + .iter() + .any(|overlay| overlay.translations.is_some()) + { + return Ok(()); + } + let report = plan + .base + .translation_stack_coverage(&overlays) + .map_err(|report| { + output::compose_error( + "verify", + serde_json::json!({"target": plan.target}), + &report, + ) + })?; + let missing = report + .entries + .iter() + .filter(|entry| entry.status == TranslationStackCoverageStatus::UntranslatedFallback) + .collect::>(); + let stale = report + .entries + .iter() + .filter(|entry| entry.status == TranslationStackCoverageStatus::Stale) + .collect::>(); + + if policy == manifest::TranslationCoveragePolicy::Strict && !missing.is_empty() { + let details = missing + .iter() + .take(10) + .map(|entry| { + format!( + "{} source {:?} introduced by {:?}", + entry.source_path, entry.source_text, entry.owner + ) + }) + .collect::>(); + return Err(format!( + "translation coverage strict policy failed for target {}: {} untranslated fallback(s): {}", + plan.target, + missing.len(), + details.join(", ") + )); + } + if policy == manifest::TranslationCoveragePolicy::Strict && !stale.is_empty() { + let details = stale + .iter() + .take(10) + .map(|entry| { + format!( + "{} old {:?} -> new {:?} introduced by {:?}", + entry.source_path, + entry.old_source_text.as_deref().unwrap_or(""), + entry.source_text, + entry.owner + ) + }) + .collect::>(); + return Err(format!( + "translation stale strict policy failed for target {}: {} stale translation(s): {}", + plan.target, + stale.len(), + details.join(", ") + )); + } + if !stale.is_empty() { + warnings.push(format!( + "stale translation warning for target {}: {} stale translation(s): {}", + plan.target, + stale.len(), + stale + .iter() + .take(10) + .map(|entry| { + format!( + "{} old {:?} -> new {:?} introduced by {:?}", + entry.source_path, + entry.old_source_text.as_deref().unwrap_or(""), + entry.source_text, + entry.owner + ) + }) + .collect::>() + .join(", ") + )); + } + Ok(()) +} + +pub(crate) fn stale_translation_warnings(plan: &TargetPlan) -> Result, String> { + stale_translation_warning_messages(plan) +} + +pub(crate) fn stale_translation_warnings_for_overlays( + target: &str, + base: &CanonicalDeck, + overlays: &[(String, Overlay)], +) -> Result, String> { + stale_translation_warning_messages_for_overlays(target, base, overlays) +} + +pub(crate) fn emit_stale_translation_warnings(plan: &TargetPlan) -> Result<(), String> { + for message in stale_translation_warnings(plan)? { + eprintln!("warning: {message}"); + } + Ok(()) +} + +pub(crate) fn emit_stale_translation_warnings_for_overlays( + target: &str, + base: &CanonicalDeck, + overlays: &[(String, Overlay)], +) -> Result<(), String> { + for message in stale_translation_warnings_for_overlays(target, base, overlays)? { + eprintln!("warning: {message}"); + } + Ok(()) +} + +fn stale_translation_warning_messages(plan: &TargetPlan) -> Result, String> { + let mut messages = Vec::new(); + let mut current = plan.base.clone(); + for (planned, overlay) in &plan.overlays { + let report = current.translation_coverage(overlay).map_err(|error| { + format!( + "failed to resolve translation source fields for target {}: {error}", + plan.target + ) + })?; + if let Some(message) = stale_translation_warning_message( + &plan.target, + &planned.id, + &planned.display_file, + &report.entries, + ) { + messages.push(message); + } + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|report| { + output::compose_error( + "composition", + serde_json::json!({"target": plan.target, "overlay": planned.id}), + &report, + ) + })?; + } + Ok(messages) +} + +fn stale_translation_warning_messages_for_overlays( + target: &str, + base: &CanonicalDeck, + overlays: &[(String, Overlay)], +) -> Result, String> { + let mut messages = Vec::new(); + let mut current = base.clone(); + for (display_file, overlay) in overlays { + let report = current.translation_coverage(overlay).map_err(|error| { + format!("failed to resolve translation source fields for target {target}: {error}") + })?; + if let Some(message) = stale_translation_warning_message( + target, + overlay.id.as_str(), + display_file, + &report.entries, + ) { + messages.push(message); + } + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|report| { + output::compose_error( + "composition", + serde_json::json!({"target": target, "overlay": overlay.id.as_str()}), + &report, + ) + })?; + } + Ok(messages) +} + +fn stale_translation_warning_message( + target: &str, + overlay_id: &str, + display_file: &str, + entries: &[brain_brew_core::TranslationCoverageEntry], +) -> Option { + let stale_translations = stale_translation_warning_details(entries); + if stale_translations.is_empty() { + return None; + } + Some(format!( + "stale translation warning for target {target} overlay {overlay_id} ({display_file}): {} stale translation(s): {}", + stale_translations.len(), + stale_translations + .iter() + .take(10) + .cloned() + .collect::>() + .join(", ") + )) +} + +fn stale_translation_warning_details( + entries: &[brain_brew_core::TranslationCoverageEntry], +) -> Vec { + entries + .iter() + .filter(|entry| entry.category == TranslationCoverageCategory::StaleTranslation) + .map(|entry| { + format!( + "{} old {:?} -> new {:?}", + entry.path, + entry.old_source.as_deref().unwrap_or(""), + entry.source + ) + }) + .collect() +} + +fn verify_configured_exports( + root: &Path, + manifest: &manifest::FederatedDeckManifest, + target: &str, + deck: &CanonicalDeck, +) -> Result<(), String> { + let Some(target_entry) = manifest.targets.get(target) else { + return Ok(()); + }; + let Some(export) = &target_entry.exports.crowdanki else { + return Ok(()); + }; + if let Some(golden) = &export.golden { + verify_crowdanki_golden(root, target, golden, &export.golden_allowlist, deck)?; + } + Ok(()) +} + +fn verify_crowdanki_golden( + root: &Path, + target: &str, + golden: &str, + golden_allowlist: &[String], + deck: &CanonicalDeck, +) -> Result<(), String> { + let authorizer = PathAuthorizer::new("workspace", root)?; + let declaring = root.join("brainbrew.yaml"); + let mut golden_path = authorizer + .authorize_read( + &declaring, + format!("targets.{target}.exports.crowdanki.golden"), + golden, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + if golden_path.is_dir() { + golden_path = authorizer + .authorize_read( + &declaring, + format!("targets.{target}.exports.crowdanki.golden"), + &format!("{golden}/deck.json"), + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + } + let expected = fs::read_to_string(&golden_path) + .map_err(|error| format!("{}: {error}", golden_path.display()))?; + let actual = crowdanki::export_deck(deck) + .map_err(|error| error.to_string())? + .deck_json; + let expected_json = serde_json::from_str::(&expected) + .map_err(|error| format!("{}: {error}", golden_path.display()))?; + let actual_json = + serde_json::from_str::(&actual).expect("CrowdAnki export is valid JSON"); + let options = crowdanki::CrowdAnkiParityOptions { + allowed_path_globs: golden_allowlist.iter().cloned().collect(), + }; + if let Err(report) = crowdanki::compare_deck_json_values(&expected_json, &actual_json, &options) + { + return Err(format!( + "CrowdAnki golden mismatch for target {target}: {}\n{report}", + golden_path.display() + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use brain_brew_core::TranslationCoverageEntry; + + #[test] + fn stale_translation_warning_message_matches_existing_text() { + let entries = vec![ + TranslationCoverageEntry { + category: TranslationCoverageCategory::StaleTranslation, + path: "notes.note.finland.fields.field.capital".to_owned(), + source: "Helsinki changed".to_owned(), + old_source: Some("Helsinki".to_owned()), + translated: Some("Helsinki".to_owned()), + context: None, + }, + TranslationCoverageEntry { + category: TranslationCoverageCategory::DirectTranslation, + path: "notes.note.sweden.fields.field.capital".to_owned(), + source: "Stockholm".to_owned(), + old_source: None, + translated: Some("Stockholm".to_owned()), + context: None, + }, + ]; + + assert_eq!( + stale_translation_warning_message( + "en-standard", + "overlay.translation.en", + "overlays/languages/en.yaml", + &entries, + ), + Some( + "stale translation warning for target en-standard overlay overlay.translation.en (overlays/languages/en.yaml): 1 stale translation(s): notes.note.finland.fields.field.capital old \"Helsinki\" -> new \"Helsinki changed\"" + .to_owned() + ) + ); + } +} diff --git a/crates/brain-brew-cli/src/commands/workbench.rs b/crates/brain-brew-cli/src/commands/workbench.rs new file mode 100644 index 0000000..bbbfdfc --- /dev/null +++ b/crates/brain-brew-cli/src/commands/workbench.rs @@ -0,0 +1,5508 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; + +use axum::body::Body; +use axum::extract::{Path as AxumPath, Query, State}; +use axum::http::{StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use brain_brew_core::{ + CanonicalDeck, CardTemplate, ContentKind, ContentValidationReport, DiagnosticCategory, + DomainDiagnostic, FieldGraphReport, FieldValue, Note, NoteType, Overlay, OverlayKind, StableId, + TranslationCoverageCategory, TranslationCoverageEntry, TranslationDictionary, + TranslationStackCoverageReport, validate_deck_content, +}; +use brain_brew_formats::canonical_source_document::CanonicalScalarTarget; +use brain_brew_formats::manifest::{ + self, BuildTarget, FederatedDeckManifest, LanguageManifestEntry, MetadataCategory, + OverlayManifestEntry, TargetExports, +}; +use brain_brew_formats::overlay_source_document::{ + OverlaySourceDocument, SourceTranslationImpact, TranslationDecision, +}; +use brain_brew_formats::safe_relative_path::SafeRelativePath; +use brain_brew_formats::source_document::{EditLocation, SourceDocumentEmission}; +use brain_brew_formats::{canonical_yaml, media}; +use include_dir::{Dir, include_dir}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; +use tokio::task; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::commands::translation_overlay::sanitize_lenient_translation_overlay; +use crate::help; +use crate::io::{canonical_source_document, manifest_root, overlay_source_document, read_manifest}; +use crate::media_ownership::MediaRootSelections; +use crate::path_authorization::PathAuthorizer; +use crate::planner::{ + ManifestRegistry, MediaDeclarationProvenance, PlannedOverlay, RegistrySourceKind, + SourceProvenance as PlannedSourceProvenance, plan_manifest_target, +}; +use crate::workspace_mutation::{PlannedWorkspaceFile, commit_workspace_files, recover_workspace}; + +static EMBEDDED_WORKBENCH_ASSETS: Dir<'static> = + include_dir!("$CARGO_MANIFEST_DIR/assets/workbench"); + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + return Ok(()); + } + + let Some(subcommand) = args.first().map(String::as_str) else { + return Err(help::usage_error( + "workbench", + "usage: brainbrew workbench serve --manifest brainbrew.yaml", + )); + }; + if subcommand == "serve" + && args + .get(1) + .is_some_and(|arg| arg == "--help" || arg == "-h") + { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + return Ok(()); + } + match subcommand { + "serve" => serve_sync(parse_serve_args(&args[1..])?), + "--help" | "-h" => { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + Ok(()) + } + other => Err(format!("unexpected workbench subcommand {other:?}")), + } +} + +#[derive(Clone, Debug)] +struct ServeArgs { + manifest_path: PathBuf, + port: u16, + open_browser: bool, + dev_assets: Option, + media_roots: Vec, + enable_write: bool, +} + +fn parse_serve_args(args: &[String]) -> Result { + let mut parsed = ServeArgs { + manifest_path: PathBuf::from("brainbrew.yaml"), + port: 0, + open_browser: true, + dev_assets: None, + media_roots: Vec::new(), + enable_write: false, + }; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" => { + let Some(path) = args.get(index + 1) else { + return Err("--manifest requires a path".to_owned()); + }; + parsed.manifest_path = PathBuf::from(path); + index += 2; + } + "--port" => { + let Some(port) = args.get(index + 1) else { + return Err("--port requires a port number".to_owned()); + }; + parsed.port = port + .parse::() + .map_err(|_| format!("invalid --port value {port:?}"))?; + index += 2; + } + "--no-open" => { + parsed.open_browser = false; + index += 1; + } + "--dev-assets" => { + let Some(path) = args.get(index + 1) else { + return Err("--dev-assets requires a directory".to_owned()); + }; + parsed.dev_assets = Some(PathBuf::from(path)); + index += 2; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a directory".to_owned()); + }; + parsed.media_roots.push(path.clone()); + index += 2; + } + "--enable-write" => { + parsed.enable_write = true; + index += 1; + } + other => return Err(format!("unexpected workbench serve argument {other:?}")), + } + } + if let Some(path) = &parsed.dev_assets + && !path.is_dir() + { + return Err(format!( + "--dev-assets directory {} does not exist", + path.display() + )); + } + if parsed.enable_write && !cfg!(feature = "workbench-write-dev") { + return Err( + "--enable-write is unavailable: this binary was built without the development-only workbench-write-dev capability" + .to_owned(), + ); + } + Ok(parsed) +} + +fn serve_sync(args: ServeArgs) -> Result<(), String> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| format!("failed to start Tokio runtime: {error}"))?; + runtime.block_on(serve(args)) +} + +async fn serve(args: ServeArgs) -> Result<(), String> { + if args.enable_write { + recover_workspace(&manifest_root(&args.manifest_path))?; + } + let manifest = read_manifest(&args.manifest_path)?; + let metadata = Arc::new( + WorkspaceMetadata::load( + &args.manifest_path, + manifest, + &args.media_roots, + args.enable_write, + ) + .map_err(WorkbenchError::message)?, + ); + let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port)) + .await + .map_err(|error| format!("failed to bind workbench server: {error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("failed to inspect workbench server address: {error}"))?; + let url = format!("http://{address}"); + + println!("Workbench listening at {url}"); + io::stdout() + .flush() + .map_err(|error| format!("failed to flush workbench URL: {error}"))?; + + if args.open_browser { + open_browser(&url); + } + + axum::serve(listener, app(metadata, args.dev_assets)) + .await + .map_err(|error| format!("workbench server failed: {error}")) +} + +fn app(metadata: Arc, dev_assets: Option) -> Router { + let router = Router::new() + .route("/api/health", get(health)) + .route("/api/workspace", get(workspace)) + .route("/api/workbench/note-list", get(note_list)) + .route("/api/workbench/note-detail", get(note_detail)) + .route("/api/workbench/card-list", get(card_list)) + .route("/api/workbench/source-string-list", get(source_string_list)) + .route("/api/workbench/metadata-list", get(optional_metadata_list)) + .route( + "/api/workbench/optional-metadata-list", + get(optional_metadata_list), + ) + .route("/api/workbench/note-pivot", get(note_pivot)) + .route( + "/api/workbench/source-string-pivot", + get(source_string_pivot), + ) + .route("/api/workbench/card-pivot", get(card_pivot)) + .route("/api/workbench/comparison-pane", get(comparison_pane)) + .route("/api/workbench/metadata", get(optional_metadata)) + .route("/api/workbench/optional-metadata", get(optional_metadata)) + .route( + "/api/workbench/new-language-preview", + get(new_language_preview), + ) + .route("/api/workbench/new-language", post(create_new_language)) + .route("/api/workbench/apply-preview", post(apply_preview)) + .route("/api/workbench/apply", post(apply_edits)) + .route("/api/media/{*path}", get(media_asset)) + .route("/favicon.ico", get(favicon)) + .with_state(metadata); + + if let Some(assets) = dev_assets { + router.fallback_service( + ServeDir::new(&assets).not_found_service(ServeFile::new(assets.join("index.html"))), + ) + } else { + router.fallback(get(embedded_asset)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkbenchErrorKind { + InvalidRequest, + NotFound, + Conflict, + ReadOnly, + Domain, + Adapter, + Internal, +} + +impl WorkbenchErrorKind { + fn status(self) -> StatusCode { + match self { + Self::InvalidRequest => StatusCode::BAD_REQUEST, + Self::NotFound => StatusCode::NOT_FOUND, + Self::Conflict => StatusCode::CONFLICT, + Self::ReadOnly => StatusCode::FORBIDDEN, + Self::Domain => StatusCode::UNPROCESSABLE_ENTITY, + Self::Adapter | Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::NotFound => "not_found", + Self::Conflict => "workspace_conflict", + Self::ReadOnly => "workbench_read_only", + Self::Domain => "domain_failed", + Self::Adapter => "adapter_error", + Self::Internal => "workbench_internal_error", + } + } + + fn category(self) -> &'static str { + match self { + Self::InvalidRequest => "request", + Self::NotFound => "not_found", + Self::Conflict => "conflict", + Self::ReadOnly => "authorization", + Self::Domain => "domain", + Self::Adapter => "adapter", + Self::Internal => "internal", + } + } +} + +#[derive(Debug)] +struct WorkbenchError { + kind: WorkbenchErrorKind, + message: String, + diagnostics: Vec, +} + +type WorkbenchResult = Result; +type WorkbenchCoreResult = Result; + +impl WorkbenchError { + fn new(kind: WorkbenchErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + diagnostics: Vec::new(), + } + } + + fn request(message: impl Into) -> Self { + Self::new(WorkbenchErrorKind::InvalidRequest, message) + } + fn not_found(message: impl Into) -> Self { + Self::new(WorkbenchErrorKind::NotFound, message) + } + fn conflict(message: impl Into) -> Self { + Self::new(WorkbenchErrorKind::Conflict, message) + } + fn adapter(message: impl Into) -> Self { + Self::new(WorkbenchErrorKind::Adapter, message) + } + fn internal(message: impl Into) -> Self { + Self::new(WorkbenchErrorKind::Internal, message) + } + fn message(self) -> String { + self.message + } + + fn domain(message: impl Into, diagnostics: Vec) -> Self { + Self { + kind: WorkbenchErrorKind::Domain, + message: message.into(), + diagnostics, + } + } + + fn compose(message: impl Into, report: brain_brew_core::ComposeReport) -> Self { + Self::domain( + message, + report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect(), + ) + } + + fn field_graph(message: impl Into, report: FieldGraphReport) -> Self { + Self::domain( + message, + report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect(), + ) + } + + fn render(message: impl Into, report: brain_brew_core::VariableRenderReport) -> Self { + Self::domain(message, report.diagnostics()) + } +} + +impl From for WorkbenchError { + fn from(message: String) -> Self { + Self::adapter(message) + } +} + +impl IntoResponse for WorkbenchError { + fn into_response(self) -> Response { + let code = if self.kind == WorkbenchErrorKind::Domain { + self.diagnostics + .first() + .map(|item| item.code) + .unwrap_or(self.kind.code()) + } else { + self.kind.code() + }; + let category = if self.kind == WorkbenchErrorKind::Domain { + self.diagnostics + .first() + .map(|item| item.category.as_str()) + .unwrap_or(self.kind.category()) + } else { + self.kind.category() + }; + let diagnostics = self + .diagnostics + .iter() + .map(crate::output::diagnostic_json) + .collect::>(); + ( + self.kind.status(), + Json(json!({ + "error": { + "schema_version": crate::output::DIAGNOSTIC_SCHEMA_VERSION, + "code": code, + "category": category, + "message": self.message, + "diagnostics": diagnostics, + } + })), + ) + .into_response() + } +} + +async fn health(State(metadata): State>) -> Json { + Json(json!({ + "status": "ok", + "manifest": metadata.manifest_path.display().to_string(), + })) +} + +async fn workspace(State(metadata): State>) -> WorkbenchResult> { + task::spawn_blocking(move || metadata.workspace_json().map(Json)) + .await + .map_err(|error| WorkbenchError::internal(format!("workbench task failed: {error}")))? +} + +async fn note_list( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.note_list_json(&query).map(Json)).await +} + +async fn note_detail( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.note_detail_json(&query).map(Json)).await +} + +async fn card_list( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.card_list_json(&query).map(Json)).await +} + +async fn source_string_list( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.source_string_list_json(&query).map(Json)).await +} + +async fn optional_metadata_list( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.optional_metadata_list_json(&query).map(Json)).await +} + +async fn note_pivot( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.note_pivot_json(&query).map(Json)).await +} + +async fn source_string_pivot( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.source_string_pivot_json(&query).map(Json)).await +} + +async fn card_pivot( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.card_pivot_json(&query).map(Json)).await +} + +async fn comparison_pane( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.comparison_pane_json(&query).map(Json)).await +} + +async fn optional_metadata( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.optional_metadata_json(&query).map(Json)).await +} + +async fn new_language_preview( + State(metadata): State>, + Query(query): Query, +) -> WorkbenchResult> { + run_workbench_blocking(move || metadata.new_language_preview_json(&query).map(Json)).await +} + +async fn create_new_language( + State(metadata): State>, + Json(request): Json, +) -> WorkbenchResult> { + metadata.require_write_capability()?; + run_workbench_blocking(move || metadata.create_new_language_json(request).map(Json)).await +} + +async fn apply_preview( + State(metadata): State>, + Json(request): Json, +) -> WorkbenchResult> { + run_workbench_blocking(move || { + metadata + .apply_request_json(request, ApplyMode::Preview) + .map(Json) + }) + .await +} + +async fn apply_edits( + State(metadata): State>, + Json(request): Json, +) -> WorkbenchResult> { + metadata.require_write_capability()?; + run_workbench_blocking(move || { + metadata + .apply_request_json(request, ApplyMode::Write) + .map(Json) + }) + .await +} + +async fn media_asset( + State(metadata): State>, + AxumPath(path): AxumPath, +) -> WorkbenchResult { + run_workbench_blocking(move || metadata.media_response(&path)).await +} + +async fn favicon() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +async fn run_workbench_blocking(operation: F) -> WorkbenchResult +where + T: Send + 'static, + F: FnOnce() -> WorkbenchCoreResult + Send + 'static, +{ + task::spawn_blocking(operation) + .await + .map_err(|error| WorkbenchError::internal(format!("workbench task failed: {error}")))? +} + +async fn embedded_asset(uri: Uri) -> Response { + let requested_path = uri.path().trim_start_matches('/'); + let asset_path = if requested_path.is_empty() { + "index.html" + } else { + requested_path + }; + + if let Some(file) = EMBEDDED_WORKBENCH_ASSETS.get_file(asset_path) { + return embedded_file_response(asset_path, file.contents()); + } + + let index = EMBEDDED_WORKBENCH_ASSETS + .get_file("index.html") + .expect("embedded workbench index.html exists"); + embedded_file_response("index.html", index.contents()) +} + +fn embedded_file_response(path: &str, contents: &'static [u8]) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, embedded_content_type(path)) + .body(Body::from(contents)) + .expect("embedded asset response is valid") +} + +fn embedded_content_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("css") => "text/css; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +struct WorkspaceMetadata { + manifest_path: PathBuf, + manifest_root: PathBuf, + media_roots: MediaRootSelections, + write_enabled: bool, + cache: tokio::sync::RwLock, + apply_mutex: Mutex<()>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileSignature { + len: u64, + modified: Option, +} + +#[derive(Clone)] +struct CachedValue { + generation: u64, + value: T, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct SelectionCacheKey { + language_code: String, + target_label: String, + overlay_label: String, +} + +struct WorkspaceCache { + generation: u64, + manifest: FederatedDeckManifest, + file_signatures: BTreeMap>, + declared_media_paths: Option>>, + selected_contexts: BTreeMap>, +} + +impl WorkspaceCache { + fn new(manifest: FederatedDeckManifest) -> Self { + Self { + generation: 0, + manifest, + file_signatures: BTreeMap::new(), + declared_media_paths: None, + selected_contexts: BTreeMap::new(), + } + } + + fn clear_generation_caches(&mut self) { + self.declared_media_paths = None; + self.selected_contexts.clear(); + } +} + +impl WorkspaceMetadata { + fn load( + manifest_path: &Path, + manifest: FederatedDeckManifest, + media_root_args: &[String], + enable_write: bool, + ) -> WorkbenchCoreResult { + let root = manifest_root(manifest_path); + let registry = ManifestRegistry::load(manifest_path, &[], &[])?; + let media_roots = MediaRootSelections::parse(®istry, media_root_args, &root)?; + Ok(Self { + manifest_path: manifest_path.to_path_buf(), + manifest_root: root, + media_roots, + write_enabled: cfg!(feature = "workbench-write-dev") && enable_write, + cache: tokio::sync::RwLock::new(WorkspaceCache::new(manifest)), + apply_mutex: Mutex::new(()), + }) + } + + fn require_write_capability(&self) -> WorkbenchResult<()> { + if self.write_enabled { + Ok(()) + } else { + Err(WorkbenchError::new( + WorkbenchErrorKind::ReadOnly, + "Workbench is read-only; source mutation is unavailable in this server", + )) + } + } + + fn lock_apply(&self) -> WorkbenchCoreResult> { + self.apply_mutex + .lock() + .map_err(|_| WorkbenchError::internal("workbench apply lock poisoned")) + } + + fn current_manifest(&self) -> WorkbenchCoreResult { + self.ensure_fresh_cache()?; + Ok(self.cache.blocking_read().manifest.clone()) + } + + fn current_manifest_snapshot(&self) -> WorkbenchCoreResult<(FederatedDeckManifest, Vec)> { + let bytes = fs::read(&self.manifest_path) + .map_err(|error| format!("{}: {error}", self.manifest_path.display()))?; + let text = std::str::from_utf8(&bytes) + .map_err(|error| format!("{}: {error}", self.manifest_path.display()))?; + let manifest = manifest::from_str(text) + .map_err(|error| format!("{}: {error}", self.manifest_path.display()))?; + Ok((manifest, bytes)) + } + + fn ensure_fresh_cache(&self) -> WorkbenchCoreResult<()> { + let (generation, manifest, previous_signatures) = { + let cache = self.cache.blocking_read(); + ( + cache.generation, + cache.manifest.clone(), + cache.file_signatures.clone(), + ) + }; + let current_signatures = self.tracked_file_signatures(&manifest)?; + if previous_signatures.is_empty() { + let mut cache = self.cache.blocking_write(); + if cache.file_signatures.is_empty() { + cache.file_signatures = self.tracked_file_signatures(&cache.manifest)?; + } + return Ok(()); + } + if current_signatures == previous_signatures { + return Ok(()); + } + + let manifest_changed = current_signatures.get(&self.manifest_path) + != previous_signatures.get(&self.manifest_path); + let refreshed_manifest = if manifest_changed { + read_manifest(&self.manifest_path)? + } else { + manifest + }; + let refreshed_signatures = self.tracked_file_signatures(&refreshed_manifest)?; + + let mut cache = self.cache.blocking_write(); + if cache.generation == generation && cache.file_signatures == previous_signatures { + cache.generation += 1; + cache.manifest = refreshed_manifest; + cache.file_signatures = refreshed_signatures; + cache.clear_generation_caches(); + } + Ok(()) + } + + fn invalidate_workspace_cache_after_write(&self) -> WorkbenchCoreResult<()> { + let manifest = read_manifest(&self.manifest_path)?; + let signatures = self.tracked_file_signatures(&manifest)?; + let mut cache = self.cache.blocking_write(); + cache.generation += 1; + cache.manifest = manifest; + cache.file_signatures = signatures; + cache.clear_generation_caches(); + Ok(()) + } + + fn tracked_file_signatures( + &self, + manifest: &FederatedDeckManifest, + ) -> WorkbenchCoreResult>> { + let paths = + authorized_manifest_source_files(&self.manifest_path, &self.manifest_root, manifest)?; + Ok(paths + .into_iter() + .map(|path| { + let signature = fs::metadata(&path).ok().map(|metadata| FileSignature { + len: metadata.len(), + modified: metadata.modified().ok(), + }); + (path, signature) + }) + .collect()) + } + + fn workspace_json(&self) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let fingerprints = file_fingerprints(&self.manifest_path, &self.manifest_root, &manifest)?; + Ok(json!({ + "manifest": self.manifest_path.display().to_string(), + "manifest_root": self.manifest_root.display().to_string(), + "languages": languages_json(&manifest), + "target_labels": target_labels_json(&manifest), + "targets": targets_json(&manifest), + "translation_profile": { + "structural_fields": manifest.translation_profile.structural_fields, + "metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "metadata_paths": manifest.translation_profile.metadata_paths, + "metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "metadata_category_order": manifest.translation_profile.metadata_category_order, + }, + "fingerprints": fingerprints, + "write_capability": { + "enabled": self.write_enabled, + "mode": if self.write_enabled { "development_write" } else { "read_only" }, + "development_build": cfg!(feature = "workbench-write-dev"), + "runtime_opt_in": self.write_enabled, + "warning": if self.write_enabled { + "DEVELOPMENT WRITE MODE: source-document and recoverable transaction guards are enabled, but complete request CAS, preview binding, and security gates remain incomplete. Do not use on irreplaceable work." + } else { + "Workbench is read-only while complete compare-and-swap fingerprints, bound preview confirmation, and applicable security gates are incomplete." + }, + }, + })) + } + + fn note_list_json(&self, query: &NoteListQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(note_list_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + pagination, + )) + } + + fn note_detail_json(&self, query: &NoteDetailQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let note = query + .note + .as_deref() + .ok_or_else(|| "invalid note detail request: missing note".to_owned())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + let detail = note_pivot_json_from_context(&context, &manifest, None, Some(note)); + if detail["notes"].as_array().is_none_or(Vec::is_empty) { + return Err(WorkbenchError::not_found(format!("unknown note {note:?}"))); + } + Ok(detail) + } + + fn card_list_json(&self, query: &CardListQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(card_list_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + query.content_group.as_deref(), + pagination, + )) + } + + fn source_string_list_json(&self, query: &SourceStringListQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(source_string_list_json_from_context( + &context, + &manifest, + query.content_group.as_deref(), + query.status.as_deref(), + pagination, + )) + } + + fn optional_metadata_list_json( + &self, + query: &OptionalMetadataListQuery, + ) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(optional_metadata_list_json_from_context( + &context, &manifest, pagination, + )) + } + + fn note_pivot_json(&self, query: &NotePivotQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(note_pivot_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + query.note.as_deref(), + )) + } + + fn source_string_pivot_json( + &self, + query: &SourceStringPivotQuery, + ) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(source_string_pivot_json_from_context( + &context, + &manifest, + query.source.as_deref(), + query.content_group.as_deref(), + query.status.as_deref(), + )) + } + + fn card_pivot_json(&self, query: &CardPivotQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(card_pivot_json_from_context( + &context, + &manifest, + query.card.as_deref(), + query.filter.as_deref(), + query.content_group.as_deref(), + )) + } + + fn optional_metadata_json(&self, query: &OptionalMetadataQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(optional_metadata_json_from_context(&context, &manifest)) + } + + fn comparison_pane_json(&self, query: &ComparisonPaneQuery) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + }, + "target_label": context.selection.target_label, + "content_groups": main_field_rows(&context.source_deck, &context.selection, &context.report.entries) + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(), + "note_pivot": note_pivot_json_from_context(&context, &manifest, None, None), + "source_string_pivot": source_string_pivot_json_from_context( + &context, + &manifest, + query.source.as_deref(), + query.content_group.as_deref(), + None, + ), + "card_pivot": card_pivot_json_from_context( + &context, + &manifest, + query.card.as_deref(), + None, + query.content_group.as_deref(), + ), + })) + } + + fn new_language_preview_json( + &self, + query: &NewLanguagePreviewQuery, + ) -> WorkbenchCoreResult { + let manifest = self.current_manifest()?; + let template = if let Some(template) = &query.template { + template.clone() + } else { + default_template_language(&manifest) + .ok_or_else(|| "missing template language".to_owned())? + }; + let code = query.code.as_deref().unwrap_or("new"); + let display_name = query.display_name.as_deref().unwrap_or(code); + let request = default_new_language_request(&manifest, code, display_name, &template)?; + let (updated, overlay_writes) = + apply_new_language_request(&self.manifest_root, &manifest, &request)?; + new_language_preview_response(&updated, &request, &overlay_writes) + } + + fn create_new_language_json(&self, request: NewLanguageRequest) -> WorkbenchCoreResult { + let _apply_guard = self.lock_apply()?; + recover_workspace(&self.manifest_root)?; + let (manifest, manifest_original) = self.current_manifest_snapshot()?; + let (updated, overlay_writes) = + apply_new_language_request(&self.manifest_root, &manifest, &request)?; + let manifest_yaml = manifest::to_string(&updated).map_err(|error| error.to_string())?; + manifest::from_str(&manifest_yaml) + .map_err(|error| format!("invalid generated manifest: {error}"))?; + + let mut outputs = BTreeMap::new(); + for (relative_path, overlay) in overlay_writes { + let path = PathAuthorizer::new("workspace", &self.manifest_root)? + .authorize_create( + &self.manifest_path, + "new-language overlay file", + &relative_path, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + let provenance = brain_brew_formats::source_document::SourceProvenance::new( + path.display().to_string(), + ) + .with_source_root(self.manifest_root.display().to_string()); + let emission = OverlaySourceDocument::from_overlay(provenance, overlay) + .and_then(|document| document.emit()) + .map_err(|error| format!("invalid generated overlay {relative_path}: {error}"))?; + collect_document_emission(emission, &mut outputs)?; + } + if outputs + .insert( + self.manifest_path.clone(), + PlannedSourceOutput { + original: OriginalSourceSnapshot::Present(manifest_original), + replacement: manifest_yaml.into_bytes(), + }, + ) + .is_some() + { + return Err((format!( + "new-language overlay output conflicts with manifest {}", + self.manifest_path.display() + )) + .into()); + } + let writes = planned_workbench_outputs(&self.manifest_root, outputs)?; + commit_workspace_files(&self.manifest_root, writes).map_err(WorkbenchError::conflict)?; + self.invalidate_workspace_cache_after_write()?; + + Ok(json!({ + "created": true, + "language": request.code, + "workspace": self.workspace_json()?, + })) + } + + fn apply_request_json( + &self, + request: ApplyRequest, + mode: ApplyMode, + ) -> WorkbenchCoreResult { + let _apply_guard = if mode == ApplyMode::Write { + Some(self.lock_apply()?) + } else { + None + }; + if mode == ApplyMode::Write { + recover_workspace(&self.manifest_root)?; + } + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + request.language.as_deref(), + request.target.as_deref(), + request.overlay.as_deref(), + )?; + if request.edits.is_empty() { + return Err(WorkbenchError::request("missing staged edits")); + } + + let source_edits = request + .edits + .iter() + .filter(|edit| edit.kind == WorkbenchEditKind::Source) + .cloned() + .collect::>(); + let mut translation_groups = BTreeMap::< + (String, String, String), + (SelectedTranslationContext, Vec), + >::new(); + for edit in request + .edits + .iter() + .filter(|edit| edit.kind == WorkbenchEditKind::Translation) + { + let group_context = self.selected_translation_context( + &manifest, + edit.language.as_deref().or(request.language.as_deref()), + edit.target.as_deref().or(request.target.as_deref()), + edit.overlay.as_deref().or(request.overlay.as_deref()), + )?; + let key = ( + group_context.selection.language_code.clone(), + group_context.selection.target_label.clone(), + group_context.selection.overlay_label.clone(), + ); + translation_groups + .entry(key) + .or_insert_with(|| (group_context, Vec::new())) + .1 + .push(edit.clone()); + } + + let primary_planned = planned_overlay_for_path(&context, &context.selection.overlay_file)?; + ensure_root_source_mutable("Workbench translation edit", &primary_planned.source)?; + for include in &primary_planned.includes { + ensure_root_source_mutable("Workbench included overlay edit", include)?; + } + let mut primary_overlay_document = read_overlay_document(&context.selection.overlay_file)?; + let mut primary_overlay = primary_overlay_document.resolved_overlay().clone(); + let mut modified_base = context.base_deck.clone(); + let base_file = context.base_source.path.clone(); + let mut source_plan = apply_staged_source_edits( + &mut modified_base, + &mut primary_overlay, + &mut primary_overlay_document, + &source_edits, + &context, + &base_file, + &self.manifest_root, + )?; + + let mut validation_contexts = vec![context.clone()]; + let mut changes = Vec::new(); + for change in std::mem::take(&mut source_plan.changed_entries) { + let file = if change["mode"] == "source" { + workspace_path(&self.manifest_root, &base_file) + } else { + context.selection.overlay_display_file.clone() + }; + changes.push(annotated_apply_change( + change, + &file, + &context, + &modified_base, + )); + } + + let mut validation = + validate_modified_base_and_overlay(&context, &modified_base, &primary_overlay)?; + let mut overlay_writes = + BTreeMap::::new(); + if source_plan.overlay_changed { + overlay_writes.insert( + context.selection.overlay_file.clone(), + ( + context.selection.overlay_display_file.clone(), + primary_overlay_document.clone(), + primary_overlay.clone(), + true, + ), + ); + } + + for (_, (group_context, translation_edits)) in translation_groups { + validation_contexts.push(group_context.clone()); + let planned = + planned_overlay_for_path(&group_context, &group_context.selection.overlay_file)?; + ensure_root_source_mutable("Workbench translation edit", &planned.source)?; + for include in &planned.includes { + ensure_root_source_mutable("Workbench included overlay edit", include)?; + } + let (mut document, mut overlay) = + if group_context.selection.overlay_file == context.selection.overlay_file { + (primary_overlay_document.clone(), primary_overlay.clone()) + } else { + let document = read_overlay_document(&group_context.selection.overlay_file)?; + let overlay = document.resolved_overlay().clone(); + (document, overlay) + }; + let context_after_source = context_with_modified_base_and_overlay( + &group_context, + modified_base.clone(), + &overlay, + )?; + let group_changes = apply_staged_edits_to_overlay( + &mut overlay, + &mut document, + &translation_edits, + &context_after_source, + )?; + for change in group_changes { + changes.push(annotated_apply_change( + change, + &group_context.selection.overlay_display_file, + &group_context, + &modified_base, + )); + } + let group_validation = + validate_modified_base_and_overlay(&group_context, &modified_base, &overlay)?; + validation.extend(group_validation.diagnostics); + overlay_writes.insert( + group_context.selection.overlay_file.clone(), + ( + group_context.selection.overlay_display_file.clone(), + document, + overlay, + !translation_edits.is_empty(), + ), + ); + } + + if changes.is_empty() { + return Err(WorkbenchError::request("missing staged edits")); + } + let mut outputs = source_plan.outputs.clone(); + for (display_file, document, _overlay, changed) in overlay_writes.values() { + if !changed { + continue; + } + collect_document_emission( + document.emit().map_err(|error| { + format!("invalid generated translation overlay {display_file}: {error}") + })?, + &mut outputs, + )?; + } + validation.extend(validate_complete_workbench_result( + &modified_base, + &overlay_writes, + &validation_contexts, + )?); + if mode == ApplyMode::Write && !validation.diagnostics.is_empty() { + return Err(WorkbenchError::domain( + "validation failed; preview before applying", + validation.diagnostics, + )); + } + if mode == ApplyMode::Write { + let writes = planned_workbench_outputs(&self.manifest_root, outputs)?; + commit_workspace_files(&self.manifest_root, writes) + .map_err(WorkbenchError::conflict)?; + self.invalidate_workspace_cache_after_write()?; + } + + let mut affected_files = source_plan.affected_files_json(&self.manifest_root); + for (path, (display_file, _document, _overlay, changed)) in &overlay_writes { + if *changed { + push_unique_affected_file( + &mut affected_files, + &self.manifest_root, + path, + Some(display_file), + ); + } + } + let file_groups = apply_file_groups_json(&changes); + let validation_ok = validation.diagnostics.is_empty(); + let validation_diagnostics = validation + .diagnostics + .iter() + .map(crate::output::diagnostic_json) + .collect::>(); + + Ok(json!({ + "mode": mode.as_str(), + "applied": mode == ApplyMode::Write, + "language": context.selection.language_code, + "target_label": context.selection.target_label, + "target_id": context.selection.target_id, + "overlay_label": context.selection.overlay_label, + "overlay_id": context.selection.overlay_id, + "affected_files": affected_files, + "file_groups": file_groups, + "changed_entries": changes, + "validation": { + "schema_version": crate::output::DIAGNOSTIC_SCHEMA_VERSION, + "ok": validation_ok, + "diagnostics": validation_diagnostics, + }, + })) + } + + fn media_response(&self, requested_path: &str) -> WorkbenchCoreResult { + let requested_path = safe_media_relative_path(requested_path)?; + let declaration = self.media_declaration(&requested_path)?.ok_or_else(|| { + WorkbenchError::not_found(format!("unknown media asset {requested_path:?}")) + })?; + let root = if let Some(root) = self.media_roots.explicit_for_declaration(&declaration) { + root.to_path_buf() + } else if self.media_roots.supplied() { + self.media_roots + .require_for_declaration("Workbench media catalog", &declaration)? + .to_path_buf() + } else { + // Compatibility fallback is owner-derived, never target-root-derived: + // package_root/media wins when present, otherwise package_root. + let conventional = declaration.package_root.join("media"); + if conventional.is_dir() { + conventional + } else { + declaration.package_root.clone() + } + }; + let authorizer = PathAuthorizer::new( + format!( + "Workbench media for package {}", + declaration.package_label() + ), + &root, + )?; + match authorizer.authorize_read( + &declaration.source, + format!("media.{}.path", declaration.id), + &requested_path, + ) { + Ok(path) => { + let path = path.into_path_buf(); + let bytes = + fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, media_content_type(&requested_path)) + .body(Body::from(bytes)) + .map_err(|error| { + WorkbenchError::internal(format!("failed to build media response: {error}")) + }) + } + Err(_error) if !root.join(&requested_path).exists() => { + let placeholder = missing_media_placeholder_svg(&requested_path); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/svg+xml") + .body(Body::from(placeholder)) + .map_err(|error| { + WorkbenchError::internal(format!( + "failed to build missing media placeholder: {error}" + )) + }) + } + Err(error) => Err(WorkbenchError::adapter(format!( + "Workbench media ownership error for package {}, declaration {}, path {:?}, root {}: {error}", + declaration.package_label(), + declaration.id, + declaration.path, + root.display() + ))), + } + } + + fn media_declaration( + &self, + requested_path: &str, + ) -> WorkbenchCoreResult> { + self.ensure_fresh_cache()?; + let (generation, manifest, cached) = { + let cache = self.cache.blocking_read(); + if let Some(declaration) = cache + .selected_contexts + .values() + .filter(|context| context.generation == cache.generation) + .find_map(|context| { + context + .value + .media_declarations + .values() + .find(|declaration| declaration.path == requested_path) + .cloned() + }) + { + return Ok(Some(declaration)); + } + ( + cache.generation, + cache.manifest.clone(), + cache.declared_media_paths.clone(), + ) + }; + if let Some(cached) = cached + && cached.generation == generation + { + return Ok(cached.value.get(requested_path).cloned()); + } + + let mut cache = self.cache.blocking_write(); + if let Some(cached) = &cache.declared_media_paths + && cached.generation == cache.generation + { + return Ok(cached.value.get(requested_path).cloned()); + } + if cache.generation != generation { + drop(cache); + return self.media_declaration(requested_path); + } + let declarations = self.collect_declared_media_paths(&manifest)?; + let declaration = declarations.get(requested_path).cloned(); + cache.declared_media_paths = Some(CachedValue { + generation, + value: declarations, + }); + Ok(declaration) + } + + fn collect_declared_media_paths( + &self, + manifest: &FederatedDeckManifest, + ) -> WorkbenchCoreResult> { + let mut paths = BTreeMap::new(); + let registry = ManifestRegistry::load(&self.manifest_path, &[], &[])?; + for target_id in manifest.targets.keys() { + let reference = manifest + .package + .as_ref() + .map(|package| format!("{}:{target_id}", package.id)) + .unwrap_or_else(|| target_id.clone()); + let plan = registry.plan(&reference)?; + for declaration in plan.media_declarations.values() { + if let Some(previous) = paths.insert(declaration.path.clone(), declaration.clone()) + && (previous.id != declaration.id + || previous.package_root != declaration.package_root + || previous.source != declaration.source) + { + return Err((format!( + "ambiguous Workbench media path {:?}: declaration {} owned by package {} at {} conflicts with declaration {} owned by package {} at {}", + declaration.path, + previous.id, + previous.package_label(), + previous.source.display(), + declaration.id, + declaration.package_label(), + declaration.source.display() + )).into()); + } + } + } + Ok(paths) + } + + fn selected_translation_context( + &self, + manifest: &FederatedDeckManifest, + language: Option<&str>, + target_label: Option<&str>, + overlay_label: Option<&str>, + ) -> WorkbenchCoreResult { + let selection = + self.select_translation_target(manifest, language, target_label, overlay_label)?; + let key = SelectionCacheKey { + language_code: selection.language_code.clone(), + target_label: selection.target_label.clone(), + overlay_label: selection.overlay_label.clone(), + }; + let (generation, cached) = { + let cache = self.cache.blocking_read(); + (cache.generation, cache.selected_contexts.get(&key).cloned()) + }; + if let Some(cached) = cached + && cached.generation == generation + { + return Ok(cached.value); + } + + let context = self.build_selected_translation_context(selection)?; + let mut cache = self.cache.blocking_write(); + if let Some(cached) = cache.selected_contexts.get(&key) + && cached.generation == cache.generation + { + return Ok(cached.value.clone()); + } + if cache.generation == generation { + cache.selected_contexts.insert( + key, + CachedValue { + generation, + value: context.clone(), + }, + ); + } + Ok(context) + } + + fn build_selected_translation_context( + &self, + selection: WorkbenchSelection, + ) -> WorkbenchCoreResult { + let plan = plan_manifest_target( + &self.manifest_path, + &selection.target_id, + &[], + &[], + &crate::package_resolver::DiscoveryPolicy::default(), + )?; + let mut current = plan.base.clone(); + let mut diagnostic_overlays = Vec::new(); + let mut selected_source_deck = None; + let mut selected_report = None; + let mut selected_display_file = selection.overlay_display_file.clone(); + let mut selected_file = selection.overlay_file.clone(); + + for (planned, overlay) in &plan.overlays { + if planned.id == selection.overlay_id { + selected_display_file = planned.display_file.clone(); + selected_file = planned.file.clone(); + selected_source_deck = Some(current.clone()); + selected_report = + Some(current.translation_coverage(overlay).map_err(|report| { + WorkbenchError::field_graph( + "failed to resolve translation source fields", + report, + ) + })?); + } + let effective_overlay = if overlay.translations.is_some() { + sanitize_lenient_translation_overlay(¤t, overlay).map_err(|report| { + WorkbenchError::field_graph( + format!("failed to resolve translation overlay {}", planned.id), + report, + ) + })? + } else { + overlay.clone() + }; + current = current + .compose(std::slice::from_ref(&effective_overlay)) + .map_err(|report| { + WorkbenchError::compose( + format!("failed to compose overlay {}", planned.id), + report, + ) + })?; + diagnostic_overlays.push(effective_overlay); + } + plan.base.compose(&diagnostic_overlays).map_err(|report| { + WorkbenchError::compose( + format!("failed to compose target {}", selection.target_id), + report, + ) + })?; + let final_report = plan + .base + .translation_stack_coverage(&diagnostic_overlays) + .map_err(|report| { + WorkbenchError::compose( + format!( + "failed to resolve final translation coverage for target {}", + selection.target_id + ), + report, + ) + })?; + + let Some(source_deck) = selected_source_deck else { + return Err((format!( + "target {} does not include translation overlay {}", + selection.target_id, selection.overlay_id + )) + .into()); + }; + let Some(report) = selected_report else { + return Err((format!( + "overlay {} is not a translation overlay", + selection.overlay_id + )) + .into()); + }; + Ok(SelectedTranslationContext { + selection: WorkbenchSelection { + overlay_file: selected_file, + overlay_display_file: selected_display_file, + ..selection + }, + base_deck: plan.base, + base_source: plan.base_source, + base_includes: plan.base_includes, + plan_overlays: plan.overlays, + media_declarations: plan.media_declarations, + source_deck, + target_deck: current, + report, + final_report, + }) + } + + fn select_translation_target( + &self, + manifest: &FederatedDeckManifest, + language: Option<&str>, + target_label: Option<&str>, + overlay_label: Option<&str>, + ) -> WorkbenchCoreResult { + let (language_code, language_entry) = if let Some(language) = language { + let entry = manifest.languages.get(language).ok_or_else(|| { + WorkbenchError::not_found(format!("unknown language {language:?}")) + })?; + (language.to_owned(), entry) + } else { + manifest + .languages + .iter() + .find(|(_, entry)| !entry.source && !entry.translation_overlays.is_empty()) + .map(|(code, entry)| (code.clone(), entry)) + .ok_or_else(|| { + WorkbenchError::not_found( + "no target language with translation overlays is configured", + ) + })? + }; + if language_entry.source { + return Err(WorkbenchError::request(format!( + "invalid source language {language_code:?}; choose a target language" + ))); + } + if language_entry.translation_overlays.is_empty() { + return Err(WorkbenchError::request(format!( + "language {language_code:?} has no translation overlays" + ))); + } + + let target_label = target_label + .map(str::to_owned) + .or_else(|| { + (!language_entry.primary_target.is_empty()) + .then(|| language_entry.primary_target.clone()) + }) + .or_else(|| language_entry.targets.keys().next().cloned()) + .ok_or_else(|| { + WorkbenchError::not_found(format!("language {language_code:?} has no targets")) + })?; + let target_id = language_entry + .targets + .get(&target_label) + .ok_or_else(|| { + WorkbenchError::not_found(format!( + "language {language_code:?} has no target label {target_label:?}" + )) + })? + .clone(); + + let overlay_label = overlay_label + .map(str::to_owned) + .or_else(|| { + language_entry + .translation_overlays + .contains_key("base") + .then(|| "base".to_owned()) + }) + .or_else(|| language_entry.translation_overlays.keys().next().cloned()) + .ok_or_else(|| { + WorkbenchError::not_found(format!( + "language {language_code:?} has no translation overlays" + )) + })?; + let overlay_id = language_entry + .translation_overlays + .get(&overlay_label) + .ok_or_else(|| { + WorkbenchError::not_found(format!( + "language {language_code:?} has no overlay label {overlay_label:?}" + )) + })? + .clone(); + let target_reference = if target_id.contains(':') { + target_id.clone() + } else { + manifest + .package + .as_ref() + .map(|package| format!("{}:{target_id}", package.id)) + .unwrap_or_else(|| target_id.clone()) + }; + let plan = plan_manifest_target( + &self.manifest_path, + &target_reference, + &[], + &[], + &crate::package_resolver::DiscoveryPolicy::default(), + )?; + let planned_overlay = plan + .overlays + .iter() + .find(|(planned, _)| planned.id == overlay_id || planned.qualified_id == overlay_id) + .map(|(planned, _)| planned) + .ok_or_else(|| { + format!( + "target {:?} does not include translation overlay {:?}", + plan.qualified_name, overlay_id + ) + })?; + let overlay_file = planned_overlay.file.clone(); + let overlay_display_file = planned_overlay.display_file.clone(); + + let overlay_badges = language_entry + .translation_overlays + .iter() + .map(|(label, id)| OverlayBadge { + label: label.clone(), + id: id.clone(), + active: label == &overlay_label, + }) + .collect(); + + Ok(WorkbenchSelection { + language_code, + language_display_name: language_entry.display_name.clone(), + target_label, + target_id: plan.qualified_name, + overlay_label, + overlay_id, + overlay_file, + overlay_display_file, + overlay_badges, + structural_fields: manifest + .translation_profile + .structural_fields + .iter() + .cloned() + .collect(), + metadata_categories: manifest.translation_profile.metadata_categories.clone(), + metadata_exclude_paths: manifest.translation_profile.metadata_exclude_paths.clone(), + }) + } +} + +fn annotated_apply_change( + mut change: Value, + file: &str, + context: &SelectedTranslationContext, + deck: &CanonicalDeck, +) -> Value { + let path = change["path"].as_str().unwrap_or("").to_owned(); + change["file"] = json!(file); + change["file_kind"] = json!(if change["mode"] == "source" { + "canonical_deck" + } else { + "translation_overlay" + }); + change["language"] = json!(&context.selection.language_code); + change["target_label"] = json!(&context.selection.target_label); + change["overlay_label"] = json!(&context.selection.overlay_label); + change["content_groups"] = json!(content_groups_for_apply_path(deck, &path)); + change +} + +fn content_groups_for_apply_path(deck: &CanonicalDeck, path: &str) -> BTreeSet { + let mut groups = path + .strip_prefix("notes.") + .and_then(|suffix| suffix.split_once(".fields.")) + .and_then(|(note_id, _)| StableId::new(note_id.to_owned()).ok()) + .map(|note_id| content_group_badges_for_note(deck, ¬e_id)) + .unwrap_or_default(); + if groups.is_empty() { + groups.insert("workspace".to_owned()); + } + groups +} + +fn apply_file_groups_json(changes: &[Value]) -> Value { + let mut grouped = BTreeMap::)>::new(); + for change in changes { + let file = change["file"].as_str().unwrap_or("unknown").to_owned(); + let file_kind = change["file_kind"].as_str().unwrap_or("unknown").to_owned(); + let content_groups = change["content_groups"] + .as_array() + .into_iter() + .flatten() + .filter_map(|group| group.as_str()) + .collect::>(); + let (_, groups) = grouped + .entry(file) + .or_insert_with(|| (file_kind, BTreeMap::new())); + if content_groups.is_empty() { + *groups.entry("workspace".to_owned()).or_insert(0) += 1; + } else { + for group in content_groups { + *groups.entry(group.to_owned()).or_insert(0) += 1; + } + } + } + json!( + grouped + .into_iter() + .map(|(file, (kind, groups))| json!({ + "file": file, + "kind": kind, + "content_groups": groups + .into_iter() + .map(|(name, change_count)| json!({ + "name": name, + "change_count": change_count, + })) + .collect::>(), + })) + .collect::>() + ) +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NoteListQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NoteDetailQuery { + language: Option, + target: Option, + overlay: Option, + note: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CardListQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + content_group: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct SourceStringListQuery { + language: Option, + target: Option, + overlay: Option, + content_group: Option, + status: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct OptionalMetadataListQuery { + language: Option, + target: Option, + overlay: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NotePivotQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + note: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct SourceStringPivotQuery { + language: Option, + target: Option, + overlay: Option, + source: Option, + content_group: Option, + status: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CardPivotQuery { + language: Option, + target: Option, + overlay: Option, + card: Option, + filter: Option, + content_group: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct OptionalMetadataQuery { + language: Option, + target: Option, + overlay: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct ComparisonPaneQuery { + language: Option, + target: Option, + overlay: Option, + source: Option, + card: Option, + content_group: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NewLanguagePreviewQuery { + code: Option, + display_name: Option, + template: Option, +} + +const DEFAULT_LIST_LIMIT: usize = 50; +const MAX_LIST_LIMIT: usize = 200; + +#[derive(Clone, Copy, Debug)] +struct Pagination { + limit: usize, + offset: usize, +} + +fn parse_pagination(limit: Option<&str>, offset: Option<&str>) -> WorkbenchCoreResult { + let limit = match limit { + Some(raw) => raw.parse::().map_err(|_| { + WorkbenchError::request(format!( + "invalid pagination limit {raw:?}: expected an integer from 1 to {MAX_LIST_LIMIT}" + )) + })?, + None => DEFAULT_LIST_LIMIT, + }; + if limit == 0 || limit > MAX_LIST_LIMIT { + return Err(WorkbenchError::request(format!( + "invalid pagination limit {limit}: expected a value from 1 to {MAX_LIST_LIMIT}" + ))); + } + let offset = match offset { + Some(raw) => raw.parse::().map_err(|_| { + WorkbenchError::request(format!( + "invalid pagination offset {raw:?}: expected a non-negative integer" + )) + })?, + None => 0, + }; + Ok(Pagination { limit, offset }) +} + +fn paginate(items: &[T], pagination: Pagination) -> (Vec, bool) { + let total = items.len(); + let page = items + .iter() + .skip(pagination.offset) + .take(pagination.limit) + .cloned() + .collect::>(); + let has_more = pagination.offset.saturating_add(pagination.limit) < total; + (page, has_more) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageRequest { + code: String, + display_name: String, + template_language: String, + primary_target: String, + groups: Vec, + targets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageGroupRequest { + label: String, + template_overlay_id: String, + overlay_id: String, + file: String, + selected: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageTargetRequest { + label: String, + target_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ApplyRequest { + language: Option, + target: Option, + overlay: Option, + edits: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct StagedWorkbenchEdit { + #[serde(default = "default_workbench_edit_kind")] + kind: WorkbenchEditKind, + language: Option, + target: Option, + overlay: Option, + path: String, + source: String, + value: String, + #[serde(default = "default_edit_mode")] + mode: EditMode, + context_path: Option, + #[serde(default = "default_source_edit_scope")] + scope: SourceEditScope, + #[serde(default = "default_source_impact_action")] + impact_action: SourceImpactAction, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum WorkbenchEditKind { + Translation, + Source, +} + +fn default_workbench_edit_kind() -> WorkbenchEditKind { + WorkbenchEditKind::Translation +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum EditMode { + Direct, + Contextual, + NoChange, +} + +fn default_edit_mode() -> EditMode { + EditMode::Direct +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SourceEditScope { + Field, + AllOccurrences, +} + +fn default_source_edit_scope() -> SourceEditScope { + SourceEditScope::Field +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SourceImpactAction { + StaleTranslation, + MigrateKey, +} + +fn default_source_impact_action() -> SourceImpactAction { + SourceImpactAction::StaleTranslation +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ApplyMode { + Preview, + Write, +} + +impl ApplyMode { + fn as_str(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Write => "write", + } + } +} + +#[derive(Clone, Debug)] +struct WorkbenchSelection { + language_code: String, + language_display_name: String, + target_label: String, + target_id: String, + overlay_label: String, + overlay_id: String, + overlay_file: PathBuf, + overlay_display_file: String, + overlay_badges: Vec, + structural_fields: BTreeSet, + metadata_categories: Vec, + metadata_exclude_paths: Vec, +} + +#[derive(Clone, Debug)] +struct OverlayBadge { + label: String, + id: String, + active: bool, +} + +#[derive(Clone)] +struct SelectedTranslationContext { + selection: WorkbenchSelection, + base_deck: CanonicalDeck, + base_source: PlannedSourceProvenance, + base_includes: Vec, + plan_overlays: Vec<(PlannedOverlay, Overlay)>, + media_declarations: BTreeMap, + source_deck: CanonicalDeck, + target_deck: CanonicalDeck, + report: brain_brew_core::TranslationCoverageReport, + /// Final-stack totals use the same pure-core resolver as verification. + final_report: TranslationStackCoverageReport, +} + +#[derive(Clone, Debug)] +struct ApplyValidation { + diagnostics: Vec, +} + +impl ApplyValidation { + fn extend(&mut self, diagnostics: impl IntoIterator) { + for diagnostic in diagnostics { + if !self.diagnostics.contains(&diagnostic) { + self.diagnostics.push(diagnostic); + } + } + } +} + +fn default_template_language(manifest: &FederatedDeckManifest) -> Option { + manifest + .languages + .iter() + .find(|(_, language)| !language.source && !language.translation_overlays.is_empty()) + .map(|(code, _)| code.clone()) +} + +fn default_new_language_request( + manifest: &FederatedDeckManifest, + code: &str, + display_name: &str, + template_language: &str, +) -> WorkbenchCoreResult { + let template = manifest.languages.get(template_language).ok_or_else(|| { + WorkbenchError::request(format!("unknown template language {template_language:?}")) + })?; + if template.source || template.translation_overlays.is_empty() { + return Err(WorkbenchError::request(format!( + "invalid template language {template_language:?}; choose a target language" + ))); + } + + let groups = template + .translation_overlays + .iter() + .map(|(label, template_overlay_id)| { + default_new_language_group(code, label, template_overlay_id) + }) + .collect(); + let targets = template + .targets + .keys() + .map(|label| NewLanguageTargetRequest { + label: label.clone(), + target_id: format!("{code}-{label}"), + }) + .collect(); + + Ok(NewLanguageRequest { + code: code.to_owned(), + display_name: display_name.to_owned(), + template_language: template_language.to_owned(), + primary_target: template.primary_target.clone(), + groups, + targets, + }) +} + +fn default_new_language_group( + code: &str, + label: &str, + template_overlay_id: &str, +) -> NewLanguageGroupRequest { + let (overlay_id, file) = if label == "base" { + ( + format!("overlay.translation.{code}"), + format!("overlays/languages/{code}.yaml"), + ) + } else { + ( + format!("overlay.translation.{label}.{code}"), + format!("overlays/languages/{label}/{code}.yaml"), + ) + }; + NewLanguageGroupRequest { + label: label.to_owned(), + template_overlay_id: template_overlay_id.to_owned(), + overlay_id, + file, + selected: true, + } +} + +fn apply_new_language_request( + manifest_root: &Path, + manifest: &FederatedDeckManifest, + request: &NewLanguageRequest, +) -> WorkbenchCoreResult<(FederatedDeckManifest, Vec<(String, Overlay)>)> { + validate_new_language_code(&request.code)?; + if request.display_name.trim().is_empty() { + return Err(WorkbenchError::request( + "invalid new language display name: expected a non-empty value", + )); + } + if manifest.languages.contains_key(&request.code) { + return Err(WorkbenchError::request(format!( + "invalid new language code {:?}: language already exists", + request.code + ))); + } + let template = manifest + .languages + .get(&request.template_language) + .ok_or_else(|| { + WorkbenchError::request(format!( + "unknown template language {:?}", + request.template_language + )) + })?; + if template.source || template.translation_overlays.is_empty() { + return Err(WorkbenchError::request(format!( + "invalid template language {:?}; choose a target language", + request.template_language + ))); + } + + let template_translation_ids = template + .translation_overlays + .values() + .cloned() + .collect::>(); + let selected_groups = selected_new_language_groups(template, request)?; + if selected_groups.is_empty() { + return Err(WorkbenchError::request( + "invalid new language scaffold: select at least one translation overlay group", + )); + } + let selected_template_to_new = selected_groups + .iter() + .map(|group| (group.template_overlay_id.clone(), group.overlay_id.clone())) + .collect::>(); + + let mut updated = manifest.clone(); + let mut overlay_writes = Vec::new(); + for group in &selected_groups { + validate_stable_id("overlay id", &group.overlay_id)?; + if manifest.overlays.contains_key(&group.overlay_id) { + return Err(WorkbenchError::request(format!( + "invalid new language overlay id {:?}: overlay already exists", + group.overlay_id + ))); + } + let absolute_path = PathAuthorizer::new("workspace", manifest_root)? + .authorize_create( + &manifest_root.join("brainbrew.yaml"), + format!("overlays.{}.file", group.overlay_id), + &group.file, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + if absolute_path.exists() { + return Err(WorkbenchError::request(format!( + "invalid new language overlay file already exists: {}", + absolute_path.display() + ))); + } + let template_overlay = manifest + .overlays + .get(&group.template_overlay_id) + .ok_or_else(|| { + WorkbenchError::request(format!( + "unknown template overlay {:?}", + group.template_overlay_id + )) + })?; + let depends_on = template_overlay + .depends_on + .iter() + .filter_map(|dependency| { + if let Some(replacement) = selected_template_to_new.get(dependency) { + Some(replacement.clone()) + } else if template_translation_ids.contains(dependency) { + None + } else { + Some(dependency.clone()) + } + }) + .collect(); + updated.overlays.insert( + group.overlay_id.clone(), + OverlayManifestEntry { + file: group.file.clone(), + kind: Some("translation".to_owned()), + depends_on, + }, + ); + overlay_writes.push(( + group.file.clone(), + Overlay { + id: StableId::new(group.overlay_id.clone()).expect("validated stable id"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary::default()), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }, + )); + } + + let mut language_targets = BTreeMap::new(); + for target in &request.targets { + validate_stable_id("target id", &target.target_id)?; + if manifest.targets.contains_key(&target.target_id) { + return Err(WorkbenchError::request(format!( + "invalid new language target id {:?}: target already exists", + target.target_id + ))); + } + let template_target_id = template.targets.get(&target.label).ok_or_else(|| { + WorkbenchError::request(format!( + "invalid new language target label {:?}: not found on template language", + target.label + )) + })?; + let template_target = manifest.targets.get(template_target_id).ok_or_else(|| { + WorkbenchError::request(format!("unknown template target {template_target_id:?}")) + })?; + let overlays = template_target + .overlays + .iter() + .filter_map(|overlay_id| { + if let Some(replacement) = selected_template_to_new.get(overlay_id) { + Some(replacement.clone()) + } else if template_translation_ids.contains(overlay_id) { + None + } else { + Some(overlay_id.clone()) + } + }) + .collect::>(); + updated.targets.insert( + target.target_id.clone(), + BuildTarget { + extends: template_target.extends.clone(), + overlays, + translation_coverage: Default::default(), + exports: TargetExports::default(), + }, + ); + language_targets.insert(target.label.clone(), target.target_id.clone()); + } + if language_targets.is_empty() { + return Err(WorkbenchError::request( + "invalid new language scaffold: expected at least one target", + )); + } + if !language_targets.contains_key(&request.primary_target) { + return Err(WorkbenchError::request(format!( + "invalid new language primary target {:?}: not found in target labels", + request.primary_target + ))); + } + + let translation_overlays = selected_groups + .iter() + .map(|group| (group.label.clone(), group.overlay_id.clone())) + .collect(); + updated.languages.insert( + request.code.clone(), + LanguageManifestEntry { + display_name: request.display_name.clone(), + source: false, + translation_overlays, + primary_target: request.primary_target.clone(), + targets: language_targets, + }, + ); + + let manifest_yaml = manifest::to_string(&updated).map_err(|error| error.to_string())?; + manifest::from_str(&manifest_yaml) + .map_err(|error| format!("invalid generated manifest: {error}"))?; + + Ok((updated, overlay_writes)) +} + +fn selected_new_language_groups<'a>( + template: &LanguageManifestEntry, + request: &'a NewLanguageRequest, +) -> WorkbenchCoreResult> { + let mut seen_labels = BTreeSet::new(); + let mut selected = Vec::new(); + for group in &request.groups { + if !seen_labels.insert(group.label.clone()) { + return Err(WorkbenchError::request(format!( + "invalid new language overlay group {:?}: duplicate label", + group.label + ))); + } + let Some(template_overlay_id) = template.translation_overlays.get(&group.label) else { + return Err(WorkbenchError::request(format!( + "invalid new language overlay group {:?}: not found on template language", + group.label + ))); + }; + if template_overlay_id != &group.template_overlay_id { + return Err(WorkbenchError::request(format!( + "invalid new language overlay group {:?}: template overlay changed", + group.label + ))); + } + if group.selected { + selected.push(group); + } + } + Ok(selected) +} + +fn new_language_preview_response( + manifest: &FederatedDeckManifest, + request: &NewLanguageRequest, + overlay_writes: &[(String, Overlay)], +) -> WorkbenchCoreResult { + let overlay_files = overlay_writes + .iter() + .map(|(path, overlay)| { + Ok(json!({ + "path": path, + "contents": canonical_yaml::overlay_to_string(overlay) + .map_err(|error| error.to_string())?, + })) + }) + .collect::>>()?; + let manifest_yaml = manifest::to_string(manifest).map_err(|error| error.to_string())?; + let mut affected_files = vec![json!({ "path": "brainbrew.yaml" })]; + affected_files.extend( + overlay_writes + .iter() + .map(|(path, _)| json!({ "path": path })), + ); + Ok(json!({ + "validation": { + "schema_version": crate::output::DIAGNOSTIC_SCHEMA_VERSION, + "ok": true, + "diagnostics": Vec::::new(), + }, + "draft": request, + "language": { + "code": &request.code, + "display_name": &request.display_name, + "template_language": &request.template_language, + "primary_target": &request.primary_target, + }, + "groups": &request.groups, + "targets": &request.targets, + "affected_files": affected_files, + "overlay_files": overlay_files, + "manifest_yaml": manifest_yaml, + })) +} + +fn validate_new_language_code(code: &str) -> WorkbenchCoreResult<()> { + if code.is_empty() + || !code + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(WorkbenchError::request(format!( + "invalid new language code {code:?}: expected letters, numbers, '-' or '_'" + ))); + } + Ok(()) +} + +fn validate_stable_id(kind: &str, value: &str) -> WorkbenchCoreResult<()> { + StableId::new(value.to_owned()) + .map(|_| ()) + .map_err(|error| { + WorkbenchError::request(format!("invalid new language {kind} {value:?}: {error}")) + }) +} + +fn note_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + pagination: Pagination, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let progress = main_progress(&context.source_deck, context, &entries_by_path); + let metadata_progress = optional_metadata_progress(&optional_metadata_rows(context, manifest)); + let rows = note_navigation_rows(context, filter); + let total = rows.len(); + let (page, has_more) = paginate(&rows, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + }, + "progress": progress, + "metadata_progress": metadata_progress, + "tombstones": workbench_tombstones_json(context), + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn card_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + content_group_filter: Option<&str>, + pagination: Pagination, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let all_cards = produced_card_rows(context, &rows); + let progress_total = all_cards.len(); + let progress_missing = all_cards + .iter() + .filter(|card| card.status == "missing") + .count(); + let progress_stale = all_cards + .iter() + .filter(|card| card.status == "stale") + .count(); + let progress_complete = progress_total.saturating_sub(progress_missing + progress_stale); + let content_groups = all_cards + .iter() + .flat_map(|card| card.content_group_badges.iter().cloned()) + .collect::>(); + let cards = all_cards + .into_iter() + .filter(|card| card_matches_filter(card, filter)) + .filter(|card| { + content_group_filter.is_none_or(|filter| { + card.content_group_badges + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let total = cards.len(); + let summaries = cards.iter().map(card_summary_json).collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + }, + "progress": { + "total": progress_total, + "complete": progress_complete, + "missing": progress_missing, + "stale": progress_stale, + "needs_work": progress_missing + progress_stale, + "percent": progress_percent(progress_complete, progress_total), + }, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn source_string_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + content_group_filter: Option<&str>, + status_filter: Option<&str>, + pagination: Pagination, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let status_filter = status_filter.filter(|filter| *filter != "all"); + let all_rows = source_string_rows(context) + .into_iter() + .filter(|row| !row.structural && !row.source.is_empty()) + .collect::>(); + let all_groups = source_string_groups(&context.source_deck, &all_rows); + let progress_total = all_groups.len(); + let progress_missing = all_groups + .iter() + .filter(|group| group.status == "missing") + .count(); + let progress_stale = all_groups + .iter() + .filter(|group| group.status == "stale") + .count(); + let progress_complete = all_groups + .iter() + .filter(|group| group.status == "complete") + .count(); + let content_groups = all_rows + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(); + let rows = all_rows + .into_iter() + .filter(|row| { + content_group_filter.is_none_or(|filter| { + content_group_badges_for_note(&context.source_deck, &row.note_id) + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let source_counts = rows + .iter() + .fold(BTreeMap::::new(), |mut counts, row| { + *counts.entry(row.source.clone()).or_insert(0) += 1; + counts + }); + let grouped = source_string_groups(&context.source_deck, &rows) + .into_iter() + .filter(|group| status_filter.is_none_or(|filter| filter == group.status)) + .collect::>(); + let total = grouped.len(); + let summaries = grouped + .iter() + .map(|group| source_string_summary_json(group, &source_counts)) + .collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + "status": status_filter.unwrap_or("all"), + "statuses": ["all", "missing", "stale", "complete"], + }, + "progress": { + "total": progress_total, + "complete": progress_complete, + "missing": progress_missing, + "stale": progress_stale, + "needs_work": progress_missing + progress_stale, + "percent": progress_percent(progress_complete, progress_total), + }, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn optional_metadata_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + pagination: Pagination, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let items = optional_metadata_rows(context, manifest); + let total = items.len(); + let summaries = items + .iter() + .map(optional_metadata_summary_json) + .collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "main_progress": main_progress(&context.source_deck, context, &entries_by_path), + "metadata_progress": optional_metadata_progress(&items), + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + "profile_metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "profile_metadata_paths": manifest.translation_profile.metadata_paths, + "profile_metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "profile_metadata_category_order": manifest.translation_profile.metadata_category_order, + }) +} + +fn note_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + selected_note: Option<&str>, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let source_counts = main_field_source_counts(&context.source_deck, &context.selection, context); + let progress = main_progress(&context.source_deck, context, &entries_by_path); + let metadata_progress = optional_metadata_progress(&optional_metadata_rows(context, manifest)); + let notes = note_pivot_notes_json( + &context.source_deck, + &context.target_deck, + context, + &entries_by_path, + &source_counts, + filter, + selected_note, + ); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "overlay_badges": overlay_badges_json(context), + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + }, + "progress": progress, + "metadata_progress": metadata_progress, + "final_coverage": final_translation_coverage_json(context), + "tombstones": workbench_tombstones_json(context), + "notes": notes, + "stale_entries": stale_entries_json(&context.report.entries), + }) +} + +fn final_translation_coverage_json(context: &SelectedTranslationContext) -> Value { + let totals = context.final_report.totals(); + json!({ + "total": totals.total, + "by_status": totals + .by_status + .into_iter() + .map(|(status, count)| (status.as_str(), count)) + .collect::>(), + "target_stack": context + .final_report + .target_stack + .iter() + .map(|id| id.as_str()) + .collect::>(), + }) +} + +fn workbench_tombstones_json(context: &SelectedTranslationContext) -> Value { + let records = |deck: &CanonicalDeck| { + deck.tombstones + .iter() + .map(|record| { + json!({ + "kind": record.address.kind(), + "path": record.address.to_string(), + "removed_by": record.provenance.as_ref().map(|value| value.overlay_id.to_string()), + "operation": record.provenance.as_ref().map(|value| value.operation.as_str()), + }) + }) + .collect::>() + }; + json!({ + "source": records(&context.source_deck), + "target": records(&context.target_deck), + }) +} + +fn optional_metadata_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let items = optional_metadata_rows(context, manifest); + let metadata_progress = optional_metadata_progress(&items); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "main_progress": main_progress(&context.source_deck, context, &entries_by_path), + "metadata_progress": metadata_progress, + "items": items.iter().map(optional_metadata_item_json).collect::>(), + "profile_metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "profile_metadata_paths": manifest.translation_profile.metadata_paths, + "profile_metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "profile_metadata_category_order": manifest.translation_profile.metadata_category_order, + }) +} + +fn source_string_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + selected_source: Option<&str>, + content_group_filter: Option<&str>, + status_filter: Option<&str>, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let status_filter = status_filter.filter(|filter| *filter != "all"); + let rows = source_string_rows(context) + .into_iter() + .filter(|row| !row.structural && !row.source.is_empty()) + .filter(|row| { + content_group_filter.is_none_or(|filter| { + content_group_badges_for_note(&context.source_deck, &row.note_id) + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let source_counts = rows + .iter() + .fold(BTreeMap::::new(), |mut counts, row| { + *counts.entry(row.source.clone()).or_insert(0) += 1; + counts + }); + let grouped = source_string_groups(&context.source_deck, &rows); + let filtered_groups = grouped + .into_iter() + .filter(|group| status_filter.is_none_or(|filter| filter == group.status)) + .collect::>(); + let selected_source = selected_source + .filter(|source| filtered_groups.iter().any(|group| group.source == *source)) + .map(str::to_owned) + .or_else(|| filtered_groups.first().map(|group| group.source.clone())); + let strings = filtered_groups + .iter() + .map(|group| { + json!({ + "source": group.source, + "status": group.status, + "main_completion_status": group.status, + "occurrence_count": group.occurrence_count, + "complete_count": group.complete_count, + "missing_count": group.missing_count, + "stale_count": group.stale_count, + "target_preview": group.target_preview, + "content_group_badges": group.content_group_badges, + "direct_recommended": true, + "direct_applies_to": source_counts.get(&group.source).copied().unwrap_or(group.occurrence_count), + "selected": selected_source.as_deref() == Some(group.source.as_str()), + }) + }) + .collect::>(); + let occurrences = selected_source + .as_deref() + .map(|source| { + rows.iter() + .filter(|row| row.source == source) + .map(|row| source_string_occurrence_json(context, row)) + .collect::>() + }) + .unwrap_or_default(); + let content_groups = rows + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + "status": status_filter.unwrap_or("all"), + "statuses": ["all", "missing", "stale", "complete"], + }, + "strings": strings, + "selected_source": selected_source, + "occurrences": occurrences, + }) +} + +fn card_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + selected_card: Option<&str>, + filter: Option<&str>, + content_group_filter: Option<&str>, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let cards = produced_card_rows(context, &rows) + .into_iter() + .filter(|card| card_matches_filter(card, filter)) + .filter(|card| { + content_group_filter.is_none_or(|filter| { + card.content_group_badges + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let selected_card_id = selected_card + .filter(|card_id| cards.iter().any(|card| card.card_id == *card_id)) + .map(str::to_owned) + .or_else(|| cards.first().map(|card| card.card_id.clone())); + let selected = selected_card_id + .as_deref() + .and_then(|card_id| cards.iter().find(|card| card.card_id == card_id)) + .map(|card| card_detail_json(context, card)); + let content_groups = cards + .iter() + .flat_map(|card| card.content_group_badges.iter().cloned()) + .collect::>(); + let total = cards.len(); + let missing = cards.iter().filter(|card| card.status == "missing").count(); + let stale = cards.iter().filter(|card| card.status == "stale").count(); + let complete = total.saturating_sub(missing + stale); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + }, + "progress": { + "total": total, + "complete": complete, + "missing": missing, + "stale": stale, + }, + "cards": cards.iter().map(card_summary_json).collect::>(), + "selected_card_id": selected_card_id, + "selected_card": selected, + }) +} + +#[derive(Clone, Debug)] +struct ProducedCardRow { + card_id: String, + note_id: StableId, + note_type_id: StableId, + template_id: StableId, + template_name: String, + title: String, + status: String, + field_rows: Vec, + content_group_badges: BTreeSet, +} + +fn produced_card_rows( + context: &SelectedTranslationContext, + rows: &[MainFieldRow], +) -> Vec { + let rows_by_note_field = rows + .iter() + .map(|row| ((row.note_id.clone(), row.field_id.clone()), row.clone())) + .collect::>(); + let mut cards = Vec::new(); + for (note_id, note) in &context.source_deck.notes { + let Some(note_type) = context.source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + for template in ¬e_type.card_templates { + let used_fields = card_used_field_ids(note_type, template); + let field_rows = used_fields + .iter() + .filter_map(|field_id| { + rows_by_note_field + .get(&(note_id.clone(), field_id.clone())) + .cloned() + }) + .collect::>(); + let status = card_status(&field_rows); + let card_id = format!("{note_id}::{}", template.id); + cards.push(ProducedCardRow { + card_id, + note_id: note_id.clone(), + note_type_id: note.note_type_id.clone(), + template_id: template.id.clone(), + template_name: template.name.clone(), + title: note_title(Some(note), Some(note_type)), + status: status.to_owned(), + field_rows, + content_group_badges: content_group_badges_for_note(&context.source_deck, note_id), + }); + } + } + cards +} + +fn card_used_field_ids(note_type: &NoteType, template: &CardTemplate) -> BTreeSet { + let template_text = format!("{}\n{}", template.question_format, template.answer_format); + let mut fields = BTreeSet::new(); + for field in ¬e_type.fields { + let name = &field.name; + let markers = [ + format!("{{{{{name}}}}}"), + format!("{{{{#{name}}}}}"), + format!("{{{{^{name}}}}}"), + format!("{{{{type:{name}}}}}"), + ]; + if markers.iter().any(|marker| template_text.contains(marker)) { + fields.insert(field.id.clone()); + } + } + if fields.is_empty() { + fields.extend(note_type.fields.iter().map(|field| field.id.clone())); + } + fields +} + +fn card_status(rows: &[MainFieldRow]) -> &'static str { + if rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) { + "missing" + } else if rows.iter().any(|row| is_stale_category(row.category)) { + "stale" + } else { + "complete" + } +} + +fn card_matches_filter(card: &ProducedCardRow, filter: Option<&str>) -> bool { + match filter.unwrap_or("all") { + "missing" => card.status == "missing", + "stale" => card.status == "stale", + "needs_work" | "needs-work" => matches!(card.status.as_str(), "missing" | "stale"), + _ => true, + } +} + +fn card_summary_json(card: &ProducedCardRow) -> Value { + json!({ + "card_id": card.card_id, + "note_id": card.note_id.to_string(), + "note_type_id": card.note_type_id.to_string(), + "template_id": card.template_id.to_string(), + "template_name": card.template_name, + "title": card.title, + "status": card.status, + "field_count": card.field_rows.len(), + "content_group_badges": card.content_group_badges, + }) +} + +fn card_detail_json(context: &SelectedTranslationContext, card: &ProducedCardRow) -> Value { + let target_fields = context + .target_deck + .resolved_field_graph() + .expect("selected Workbench target deck was graph-validated when its context was built"); + let source_note = context.source_deck.notes.get(&card.note_id); + let target_note = context.target_deck.notes.get(&card.note_id).or(source_note); + let source_note_type = context.source_deck.note_types.get(&card.note_type_id); + let target_note_type = target_note + .and_then(|note| context.target_deck.note_types.get(¬e.note_type_id)) + .or(source_note_type); + let fields = card + .field_rows + .iter() + .map(|row| { + let target = target_note + .map(|note| { + resolved_field_text_or_diagnostic(&target_fields, ¬e.id, &row.field_id) + }) + .unwrap_or_else(|| row.translated.clone()); + json!({ + "path": row.path, + "note_id": row.note_id.to_string(), + "note_type_id": row.note_type_id.to_string(), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "source": row.source, + "target": target, + "status": row.category.as_str(), + "structural": row.structural, + "editable": !row.structural, + "source_editable": true, + "context_path": contextual_path_for_row(row, &context.report.entries), + "controls": ["direct", "contextual", "no_change"], + }) + }) + .collect::>(); + json!({ + "card_id": card.card_id, + "note_id": card.note_id.to_string(), + "note_type_id": card.note_type_id.to_string(), + "template_id": card.template_id.to_string(), + "template_name": card.template_name, + "title": card.title, + "status": card.status, + "content_group_badges": card.content_group_badges, + "fields": fields, + "source_preview": source_note.and_then(|note| source_note_type.map(|note_type| render_single_note_card(&context.source_deck, note, note_type, &card.template_id))), + "target_preview": target_note.and_then(|note| target_note_type.map(|note_type| render_single_note_card(&context.target_deck, note, note_type, &card.template_id))), + }) +} + +fn render_single_note_card( + deck: &CanonicalDeck, + note: &Note, + note_type: &NoteType, + template_id: &StableId, +) -> Value { + let rendered_deck = match deck.render_variables() { + Ok(rendered) => rendered, + Err(error) => return json!({ "error": error.to_string(), "cards": [] }), + }; + let rendered_note = rendered_deck.notes.get(¬e.id).unwrap_or(note); + let rendered_note_type = rendered_deck + .note_types + .get(¬e_type.id) + .unwrap_or(note_type); + let cards = rendered_note_type + .card_templates + .iter() + .filter(|template| &template.id == template_id) + .map(|template| render_card(rendered_note, rendered_note_type, template)) + .collect::>(); + json!({ + "styling": rendered_note_type.styling, + "cards": cards, + }) +} + +fn source_string_rows(context: &SelectedTranslationContext) -> Vec { + let structural_fields = structural_field_set(&context.selection, &context.source_deck); + context + .source_deck + .translation_context(&context.report) + .expect("selected translation context was graph-validated when it was built") + .units + .into_iter() + .filter_map(|unit| { + let note_id = unit.note_id?; + let note_type_id = unit.note_type_id?; + let field_id = unit.field_id?; + let field_name = unit.field_name.unwrap_or_else(|| field_id.to_string()); + Some(MainFieldRow { + note_id, + note_type_id, + structural: structural_fields.contains(field_id.as_str()), + field_id, + field_name, + path: unit.path, + source: unit.source.clone(), + category: unit.category, + translated: unit.translated.unwrap_or(unit.source), + }) + }) + .collect() +} + +#[derive(Clone, Debug)] +struct SourceStringGroup { + source: String, + status: String, + occurrence_count: usize, + complete_count: usize, + missing_count: usize, + stale_count: usize, + target_preview: Option, + content_group_badges: BTreeSet, +} + +fn source_string_groups( + source_deck: &CanonicalDeck, + rows: &[MainFieldRow], +) -> Vec { + let mut groups = BTreeMap::>::new(); + for row in rows { + groups.entry(row.source.clone()).or_default().push(row); + } + groups + .into_iter() + .map(|(source, rows)| { + let occurrence_count = rows.len(); + let complete_count = rows + .iter() + .filter(|row| is_complete_translation_category(row.category)) + .count(); + let missing_count = rows + .iter() + .filter(|row| row.category == TranslationCoverageCategory::UntranslatedFallback) + .count(); + let stale_count = rows + .iter() + .filter(|row| is_stale_category(row.category)) + .count(); + let status = if missing_count > 0 { + "missing" + } else if stale_count > 0 { + "stale" + } else { + "complete" + } + .to_owned(); + let target_preview = rows + .iter() + .copied() + .find(|row| row.translated != row.source) + .or_else(|| rows.first().copied()) + .map(|row| row.translated.clone()); + let content_group_badges = rows + .iter() + .flat_map(|row| content_group_badges_for_note(source_deck, &row.note_id)) + .collect::>(); + SourceStringGroup { + source, + status, + occurrence_count, + complete_count, + missing_count, + stale_count, + target_preview, + content_group_badges, + } + }) + .collect() +} + +fn source_string_occurrence_json( + context: &SelectedTranslationContext, + row: &MainFieldRow, +) -> Value { + let target_fields = context + .target_deck + .resolved_field_graph() + .expect("selected Workbench target deck was graph-validated when its context was built"); + let note = context.source_deck.notes.get(&row.note_id); + let target_note = context.target_deck.notes.get(&row.note_id).or(note); + let source_note_type = context.source_deck.note_types.get(&row.note_type_id); + let target_note_type = target_note + .and_then(|note| context.target_deck.note_types.get(¬e.note_type_id)) + .or(source_note_type); + json!({ + "path": row.path, + "source": row.source, + "target": if row.path.contains(".message.") { + row.translated.clone() + } else { + target_note + .map(|note| resolved_field_text_or_diagnostic(&target_fields, ¬e.id, &row.field_id)) + .unwrap_or_else(|| row.translated.clone()) + }, + "status": row.category.as_str(), + "note_id": row.note_id.to_string(), + "note_title": note_title(note, source_note_type), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "friendly_context": format!("{} · {}", note_title(note, source_note_type), row.field_name), + "context_path": contextual_path_for_row(row, &context.report.entries), + "content_group_badges": content_group_badges_for_note(&context.source_deck, &row.note_id), + "direct_recommended": true, + "controls": ["direct", "contextual", "no_change"], + "source_preview": note.and_then(|note| source_note_type.map(|note_type| render_note_cards(&context.source_deck, note, note_type))), + "target_preview": target_note.and_then(|note| target_note_type.map(|note_type| render_note_cards(&context.target_deck, note, note_type))), + }) +} + +fn note_title(note: Option<&Note>, note_type: Option<&NoteType>) -> String { + let Some(note) = note else { + return "unknown note".to_owned(); + }; + if let Some(note_type) = note_type { + for field in ¬e_type.fields { + if let Some(value) = note + .fields + .get(&field.id) + .and_then(FieldValue::as_scalar) + .and_then(title_candidate) + { + return value; + } + } + } + note.fields + .values() + .filter_map(FieldValue::as_scalar) + .find_map(title_candidate) + .unwrap_or_else(|| note.id.to_string()) +} + +fn title_candidate(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.trim_start().starts_with('<') { + return None; + } + Some(value.to_owned()) +} + +fn content_group_badges_for_note( + source_deck: &CanonicalDeck, + note_id: &StableId, +) -> BTreeSet { + let mut badges = BTreeSet::new(); + if let Some(note) = source_deck.notes.get(note_id) { + badges.insert(note.note_type_id.to_string()); + badges.extend(note.tags.iter().cloned()); + } + badges +} + +fn is_complete_translation_category(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) +} + +fn overlay_badges_json(context: &SelectedTranslationContext) -> Value { + json!( + context + .selection + .overlay_badges + .iter() + .map(|badge| json!({ + "label": badge.label, + "id": badge.id, + "active": badge.active, + })) + .collect::>() + ) +} + +fn metadata_category_json(category: &MetadataCategory) -> Value { + json!({ + "key": category.key, + "label": category.label, + "paths": category.paths, + }) +} + +fn selection_options_json( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Value { + let languages = manifest + .languages + .iter() + .filter(|(_, entry)| !entry.source && !entry.translation_overlays.is_empty()) + .map(|(code, entry)| { + json!({ + "code": code, + "display_name": entry.display_name, + "active": code == &context.selection.language_code, + }) + }) + .collect::>(); + + let Some(language_entry) = manifest.languages.get(&context.selection.language_code) else { + return json!({ + "languages": languages, + "targets": [], + "overlays": [], + }); + }; + + let targets = language_entry + .targets + .iter() + .map(|(label, id)| { + json!({ + "label": label, + "id": id, + "active": label == &context.selection.target_label, + }) + }) + .collect::>(); + let overlays = language_entry + .translation_overlays + .iter() + .map(|(label, id)| { + json!({ + "label": label, + "id": id, + "active": label == &context.selection.overlay_label, + }) + }) + .collect::>(); + + json!({ + "languages": languages, + "targets": targets, + "overlays": overlays, + }) +} + +fn main_field_source_counts( + source_deck: &CanonicalDeck, + _selection: &WorkbenchSelection, + context: &SelectedTranslationContext, +) -> BTreeMap { + let mut counts = BTreeMap::new(); + for row in main_field_rows(source_deck, &context.selection, &context.report.entries) { + *counts.entry(row.source).or_insert(0) += 1; + } + counts +} + +#[derive(Clone, Debug)] +struct MainFieldRow { + note_id: StableId, + note_type_id: StableId, + field_id: StableId, + field_name: String, + path: String, + source: String, + structural: bool, + category: TranslationCoverageCategory, + translated: String, +} + +fn resolved_field_text_or_diagnostic( + graph: &brain_brew_core::ResolvedFieldGraph, + note_id: &StableId, + field_id: &StableId, +) -> String { + let path = brain_brew_core::DeckPath::NoteField { + note_id: note_id.clone(), + field_id: field_id.clone(), + } + .to_string(); + graph + .get(&path) + .map(str::to_owned) + .unwrap_or_else(|| format!("[field resolution error: no live value at {path}]")) +} + +fn main_field_rows( + source_deck: &CanonicalDeck, + selection: &WorkbenchSelection, + entries: &[TranslationCoverageEntry], +) -> Vec { + let structural_fields = structural_field_set(selection, source_deck); + let resolved_fields = source_deck + .resolved_field_graph() + .expect("selected Workbench source deck was graph-validated when its context was built"); + let entries_by_path = entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let mut rows = Vec::new(); + for (note_id, note) in &source_deck.notes { + let Some(note_type) = source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + for field in ¬e_type.fields { + let path = format!("notes.{note_id}.fields.{}", field.id); + let Some(source) = resolved_fields.get(&path).map(str::to_owned) else { + continue; + }; + if source.is_empty() { + continue; + } + let structural = structural_fields.contains(field.id.as_str()); + let entry = entries_by_path.get(path.as_str()).copied(); + rows.push(MainFieldRow { + note_id: note_id.clone(), + note_type_id: note.note_type_id.clone(), + field_id: field.id.clone(), + field_name: field.name.clone(), + path, + source: source.clone(), + structural, + category: entry + .map(|entry| entry.category) + .unwrap_or(TranslationCoverageCategory::UntranslatedFallback), + translated: entry + .and_then(|entry| entry.translated.clone()) + .unwrap_or(source), + }); + } + } + rows +} + +fn structural_field_set( + selection: &WorkbenchSelection, + _source_deck: &CanonicalDeck, +) -> BTreeSet { + selection.structural_fields.clone() +} + +fn main_progress( + source_deck: &CanonicalDeck, + context: &SelectedTranslationContext, + _entries_by_path: &BTreeMap<&str, &TranslationCoverageEntry>, +) -> Value { + let rows = main_field_rows(source_deck, &context.selection, &context.report.entries) + .into_iter() + .filter(|row| !row.structural) + .collect::>(); + let total = rows.len(); + let complete = rows + .iter() + .filter(|row| { + matches!( + row.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) + }) + .count(); + let missing = rows + .iter() + .filter(|row| row.category == TranslationCoverageCategory::UntranslatedFallback) + .count(); + let stale = rows + .iter() + .filter(|row| is_stale_category(row.category)) + .count(); + let percent = progress_percent(complete, total); + json!({ + "complete": complete, + "total": total, + "missing": missing, + "stale": stale, + "needs_work": missing + stale, + "percent": percent, + }) +} + +fn progress_percent(complete: usize, total: usize) -> usize { + complete + .checked_mul(100) + .and_then(|value| value.checked_div(total)) + .unwrap_or(100) +} + +#[derive(Clone, Debug)] +struct OptionalMetadataRow { + path: String, + source: String, + translated: String, + category: TranslationCoverageCategory, + metadata_category: String, + metadata_category_key: String, + profile_metadata: bool, + warning: Option, +} + +fn optional_metadata_rows( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Vec { + let main_paths = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ) + .into_iter() + .filter(|row| !row.structural) + .map(|row| row.path) + .collect::>(); + let mut rows = context + .report + .entries + .iter() + .filter(|entry| !main_paths.contains(entry.path.as_str())) + .filter(|entry| { + !path_matches_any( + &manifest.translation_profile.metadata_exclude_paths, + &entry.path, + ) + }) + .filter_map(|entry| { + let (metadata_category_key, metadata_category) = metadata_category_for_path( + &manifest.translation_profile.metadata_categories, + &entry.path, + )?; + let profile_metadata = + path_matches_any(&manifest.translation_profile.metadata_paths, &entry.path); + let warning = is_stale_category(entry.category).then(|| match &entry.old_source { + Some(old) => format!("stale: source changed from {old:?}"), + None => "stale metadata".to_owned(), + }); + Some(OptionalMetadataRow { + path: entry.path.clone(), + source: entry.source.clone(), + translated: entry + .translated + .clone() + .unwrap_or_else(|| entry.source.clone()), + category: entry.category, + metadata_category: metadata_category.to_owned(), + metadata_category_key: metadata_category_key.to_owned(), + profile_metadata, + warning, + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + optional_metadata_category_rank( + &left.metadata_category_key, + &manifest.translation_profile.metadata_category_order, + &manifest.translation_profile.metadata_categories, + ) + .cmp(&optional_metadata_category_rank( + &right.metadata_category_key, + &manifest.translation_profile.metadata_category_order, + &manifest.translation_profile.metadata_categories, + )) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.source.cmp(&right.source)) + }); + rows +} + +fn optional_metadata_progress(rows: &[OptionalMetadataRow]) -> Value { + let total = rows.len(); + let complete = rows + .iter() + .filter(|row| optional_row_status(row) == "complete") + .count(); + let missing = rows + .iter() + .filter(|row| optional_row_status(row) == "missing") + .count(); + let stale = rows + .iter() + .filter(|row| optional_row_status(row) == "stale") + .count(); + json!({ + "complete": complete, + "total": total, + "missing": missing, + "stale": stale, + "needs_work": missing + stale, + }) +} + +fn optional_metadata_item_json(row: &OptionalMetadataRow) -> Value { + json!({ + "path": row.path, + "source": row.source, + "target": row.translated, + "status": optional_row_status(row), + "coverage_category": format!("{:?}", row.category), + "metadata_category": row.metadata_category, + "metadata_category_key": row.metadata_category_key, + "profile_metadata": row.profile_metadata, + "warning": row.warning, + "editable": true, + }) +} + +fn optional_row_status(row: &OptionalMetadataRow) -> &'static str { + if is_stale_category(row.category) { + "stale" + } else if row.category == TranslationCoverageCategory::UntranslatedFallback { + "missing" + } else { + "complete" + } +} + +fn metadata_category_for_path<'a>( + categories: &'a [MetadataCategory], + path: &str, +) -> Option<(&'a str, &'a str)> { + categories + .iter() + .filter_map(|category| { + category + .paths + .iter() + .filter(|pattern| path_matches_pattern(pattern, path)) + .map(|pattern| pattern.len()) + .max() + .map(|matched_len| (matched_len, category)) + }) + .max_by_key(|(matched_len, _)| *matched_len) + .map(|(_, category)| (category.key.as_str(), category.label.as_str())) +} + +fn optional_metadata_category_rank( + category_key: &str, + configured_order: &[String], + categories: &[MetadataCategory], +) -> usize { + if let Some(index) = configured_order + .iter() + .position(|ordered| ordered == category_key) + { + return index; + } + configured_order.len() + + categories + .iter() + .position(|category| category.key == category_key) + .unwrap_or(categories.len()) +} + +fn path_matches_any(patterns: &[String], path: &str) -> bool { + patterns + .iter() + .any(|pattern| path_matches_pattern(pattern, path)) +} + +fn path_matches_pattern(pattern: &str, path: &str) -> bool { + let pattern = pattern.as_bytes(); + let path = path.as_bytes(); + let (mut pattern_index, mut path_index) = (0, 0); + let (mut star_index, mut star_path_index) = (None, 0); + + while path_index < path.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == path[path_index] { + pattern_index += 1; + path_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_path_index = path_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_path_index += 1; + path_index = star_path_index; + } else { + return false; + } + } + + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + + pattern_index == pattern.len() +} + +fn note_navigation_rows(context: &SelectedTranslationContext, filter: Option<&str>) -> Vec { + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let rows_by_note = rows.into_iter().fold( + BTreeMap::>::new(), + |mut rows_by_note, row| { + rows_by_note + .entry(row.note_id.clone()) + .or_default() + .push(row); + rows_by_note + }, + ); + let mut notes = Vec::new(); + for (index, (note_id, note)) in context.source_deck.notes.iter().enumerate() { + let field_rows = rows_by_note.get(note_id).cloned().unwrap_or_default(); + let note_entries = context + .report + .entries + .iter() + .filter(|entry| entry.path.starts_with(&format!("notes.{note_id}."))) + .cloned() + .collect::>(); + if !note_matches_filter(&field_rows, ¬e_entries, filter) { + continue; + } + let title = note_title( + Some(note), + context.source_deck.note_types.get(¬e.note_type_id), + ); + let field_count = field_rows.len(); + let translatable_field_count = field_rows.iter().filter(|row| !row.structural).count(); + let missing_count = field_rows + .iter() + .filter(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) + .count(); + let stale_count = note_entries + .iter() + .filter(|entry| is_stale_category(entry.category)) + .count(); + let complete_count = translatable_field_count.saturating_sub(missing_count + stale_count); + notes.push(json!({ + "index": index, + "note_id": note_id.to_string(), + "note_type_id": note.note_type_id.to_string(), + "title": title, + "status": note_status(&field_rows, ¬e_entries), + "field_count": field_count, + "translatable_field_count": translatable_field_count, + "complete_count": complete_count, + "missing_count": missing_count, + "stale_count": stale_count, + "content_group_badges": content_group_badges_for_note(&context.source_deck, note_id), + })); + } + notes +} + +fn source_string_summary_json( + group: &SourceStringGroup, + source_counts: &BTreeMap, +) -> Value { + json!({ + "source": group.source, + "status": group.status, + "main_completion_status": group.status, + "occurrence_count": group.occurrence_count, + "complete_count": group.complete_count, + "missing_count": group.missing_count, + "stale_count": group.stale_count, + "target_preview": group.target_preview, + "content_group_badges": group.content_group_badges, + "direct_recommended": true, + "direct_applies_to": source_counts.get(&group.source).copied().unwrap_or(group.occurrence_count), + }) +} + +fn optional_metadata_summary_json(row: &OptionalMetadataRow) -> Value { + json!({ + "path": row.path, + "source": row.source, + "status": optional_row_status(row), + "coverage_category": format!("{:?}", row.category), + "metadata_category": row.metadata_category, + "metadata_category_key": row.metadata_category_key, + "profile_metadata": row.profile_metadata, + "warning": row.warning, + "editable": true, + }) +} + +fn note_pivot_notes_json( + source_deck: &CanonicalDeck, + target_deck: &CanonicalDeck, + context: &SelectedTranslationContext, + _entries_by_path: &BTreeMap<&str, &TranslationCoverageEntry>, + source_counts: &BTreeMap, + filter: Option<&str>, + selected_note: Option<&str>, +) -> Value { + let rows = main_field_rows(source_deck, &context.selection, &context.report.entries); + let target_fields = target_deck + .resolved_field_graph() + .expect("selected Workbench target deck was graph-validated when its context was built"); + let rows_by_note = rows.into_iter().fold( + BTreeMap::>::new(), + |mut rows_by_note, row| { + rows_by_note + .entry(row.note_id.clone()) + .or_default() + .push(row); + rows_by_note + }, + ); + let mut notes = Vec::new(); + for (index, (note_id, note)) in source_deck.notes.iter().enumerate() { + if selected_note.is_some_and(|selected| selected != note_id.as_str()) { + continue; + } + let field_rows = rows_by_note.get(note_id).cloned().unwrap_or_default(); + let note_entries = context + .report + .entries + .iter() + .filter(|entry| entry.path.starts_with(&format!("notes.{note_id}."))) + .cloned() + .collect::>(); + if !note_matches_filter(&field_rows, ¬e_entries, filter) { + continue; + } + let Some(note_type) = source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + let target_note = target_deck.notes.get(note_id).unwrap_or(note); + let fields = field_rows + .iter() + .map(|row| { + let target = + resolved_field_text_or_diagnostic(&target_fields, note_id, &row.field_id); + json!({ + "path": row.path, + "note_id": row.note_id.to_string(), + "note_type_id": row.note_type_id.to_string(), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "source": row.source, + "target": target, + "status": row.category.as_str(), + "occurrence_count": source_counts.get(&row.source).copied().unwrap_or(1), + "structural": row.structural, + "editable": !row.structural, + "source_editable": true, + "context_path": contextual_path_for_row(row, &context.report.entries), + "controls": ["direct", "contextual", "no_change"], + }) + }) + .collect::>(); + let source_preview = render_note_cards(source_deck, note, note_type); + let target_note_type = target_deck + .note_types + .get(&target_note.note_type_id) + .unwrap_or(note_type); + let target_preview = render_note_cards(target_deck, target_note, target_note_type); + let title = note_title(Some(note), Some(note_type)); + notes.push(json!({ + "index": index, + "note_id": note_id.to_string(), + "note_type_id": note.note_type_id.to_string(), + "title": title, + "status": note_status(&field_rows, ¬e_entries), + "fields": fields, + "source_preview": source_preview, + "target_preview": target_preview, + })); + } + json!(notes) +} + +fn contextual_path_for_row(row: &MainFieldRow, entries: &[TranslationCoverageEntry]) -> String { + let Some(note_context) = note_context_prefix(&row.path) else { + return row.path.clone(); + }; + let note_path_prefix = format!("{note_context}."); + let same_source_in_note = entries + .iter() + .filter(|entry| { + entry.source == row.source + && (entry.path == note_context || entry.path.starts_with(¬e_path_prefix)) + }) + .count(); + if same_source_in_note <= 1 { + note_context + } else { + row.path.clone() + } +} + +fn note_context_prefix(path: &str) -> Option { + path.split_once(".fields.") + .map(|(note_context, _)| note_context.to_owned()) +} + +fn note_matches_filter( + rows: &[MainFieldRow], + entries: &[TranslationCoverageEntry], + filter: Option<&str>, +) -> bool { + match filter.unwrap_or("all") { + "missing" => rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }), + "stale" => entries + .iter() + .any(|entry| is_stale_category(entry.category)), + "needs_work" | "needs-work" => { + rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) || entries + .iter() + .any(|entry| is_stale_category(entry.category)) + } + _ => true, + } +} + +fn note_status(rows: &[MainFieldRow], entries: &[TranslationCoverageEntry]) -> &'static str { + if rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) { + "missing" + } else if entries + .iter() + .any(|entry| is_stale_category(entry.category)) + { + "stale" + } else { + "complete" + } +} + +fn stale_entries_json(entries: &[TranslationCoverageEntry]) -> Value { + json!( + entries + .iter() + .filter(|entry| is_stale_category(entry.category)) + .map(|entry| json!({ + "path": entry.path, + "source": entry.source, + "old_source": entry.old_source, + "target": entry.translated, + "status": entry.category.as_str(), + })) + .collect::>() + ) +} + +fn is_stale_category(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::InvalidTargetAdaptation + ) +} + +fn render_note_cards(deck: &CanonicalDeck, note: &Note, note_type: &NoteType) -> Value { + let rendered_deck = match deck.render_variables() { + Ok(rendered) => rendered, + Err(error) => return json!({ "error": error.to_string(), "cards": [] }), + }; + let rendered_note = rendered_deck.notes.get(¬e.id).unwrap_or(note); + let rendered_note_type = rendered_deck + .note_types + .get(¬e_type.id) + .unwrap_or(note_type); + let cards = rendered_note_type + .card_templates + .iter() + .map(|template| render_card(rendered_note, rendered_note_type, template)) + .collect::>(); + json!({ + "styling": rendered_note_type.styling, + "cards": cards, + }) +} + +fn render_card(note: &Note, note_type: &NoteType, template: &CardTemplate) -> Value { + let question = render_template_side(&template.question_format, note, note_type, ""); + let answer = render_template_side(&template.answer_format, note, note_type, &question); + json!({ + "template_id": template.id.to_string(), + "template_name": template.name, + "question_html": question, + "answer_html": answer, + }) +} + +fn render_template_side( + template: &str, + note: &Note, + note_type: &NoteType, + front_side: &str, +) -> String { + let mut rendered = template.replace("{{FrontSide}}", front_side); + for field in ¬e_type.fields { + let value = note + .fields + .get(&field.id) + .and_then(FieldValue::as_scalar) + .unwrap_or(""); + rendered = render_conditional_sections(&rendered, &field.name, value); + } + for field in ¬e_type.fields { + let value = note + .fields + .get(&field.id) + .and_then(FieldValue::as_scalar) + .unwrap_or(""); + let preview_value = format!( + "{}", + html_attribute_escape(field.id.as_str()), + value + ); + rendered = rendered.replace(&format!("{{{{{}}}}}", field.name), &preview_value); + rendered = rendered.replace(&format!("{{{{type:{}}}}}", field.name), ""); + } + rewrite_media_sources(&rendered) +} + +fn render_conditional_sections(template: &str, field_name: &str, value: &str) -> String { + let mut rendered = template.to_owned(); + rendered = replace_section( + &rendered, + &format!("{{{{#{field_name}}}}}"), + &format!("{{{{/{field_name}}}}}"), + !value.is_empty(), + ); + replace_section( + &rendered, + &format!("{{{{^{field_name}}}}}"), + &format!("{{{{/{field_name}}}}}"), + value.is_empty(), + ) +} + +fn replace_section(template: &str, open: &str, close: &str, keep: bool) -> String { + let mut output = template.to_owned(); + while let Some(start) = output.find(open) { + let content_start = start + open.len(); + let Some(close_offset) = output[content_start..].find(close) else { + break; + }; + let close_start = content_start + close_offset; + let close_end = close_start + close.len(); + let replacement = if keep { + output[content_start..close_start].to_owned() + } else { + String::new() + }; + output.replace_range(start..close_end, &replacement); + } + output +} + +fn html_attribute_escape(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn rewrite_media_sources(html: &str) -> String { + rewrite_media_source_quotes( + &rewrite_media_source_quotes(html, "src=\"", '"'), + "src='", + '\'', + ) +} + +fn rewrite_media_source_quotes(html: &str, marker: &str, quote: char) -> String { + let mut output = String::new(); + let mut rest = html; + while let Some(start) = rest.find(marker) { + let (before, after_marker) = rest.split_at(start + marker.len()); + output.push_str(before); + let Some(end) = after_marker.find(quote) else { + output.push_str(after_marker); + return output; + }; + let (path, after_path) = after_marker.split_at(end); + if is_external_or_already_routed_media(path) { + output.push_str(path); + } else { + output.push_str("/api/media/"); + output.push_str(path.trim_start_matches('/')); + } + rest = after_path; + } + output.push_str(rest); + output +} + +fn is_external_or_already_routed_media(path: &str) -> bool { + path.starts_with("/api/media/") + || path.starts_with("http://") + || path.starts_with("https://") + || path.starts_with("data:") +} + +fn safe_media_relative_path(path: &str) -> WorkbenchCoreResult { + SafeRelativePath::new(path) + .map(|path| path.as_str().to_owned()) + .map_err(|reason| WorkbenchError::request(format!("invalid media path {path:?}: {reason}"))) +} + +fn missing_media_placeholder_svg(path: &str) -> String { + let label = html_attribute_escape(path); + format!( + "Missing media asset{label}" + ) +} + +fn media_content_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("gif") => "image/gif", + Some("jpg" | "jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("svg") => "image/svg+xml", + Some("webp") => "image/webp", + _ => "application/octet-stream", + } +} + +fn read_overlay_document(path: &Path) -> WorkbenchCoreResult { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + overlay_source_document(path, &input).map_err(WorkbenchError::adapter) +} + +fn ensure_root_source_mutable( + operation: &str, + source: &PlannedSourceProvenance, +) -> WorkbenchCoreResult<()> { + if source.registry_source == RegistrySourceKind::RootManifest { + Ok(()) + } else { + Err(WorkbenchError::new( + WorkbenchErrorKind::ReadOnly, + format!( + "{operation} is read-only for {} source {} at {}; dependency/include/locked/cache sources cannot be selected for Workbench mutation", + source.registry_source.ownership_name(), + match source.kind { + crate::planner::PlanSourceKind::Base => "base", + crate::planner::PlanSourceKind::Overlay { .. } => "overlay", + crate::planner::PlanSourceKind::ScalarInclude { .. } => "scalar include", + crate::planner::PlanSourceKind::MediaInclude => "media include", + }, + source.path.display() + ), + )) + } +} + +fn planned_overlay_for_path<'a>( + context: &'a SelectedTranslationContext, + path: &Path, +) -> WorkbenchCoreResult<&'a PlannedOverlay> { + context + .plan_overlays + .iter() + .map(|(planned, _)| planned) + .find(|planned| planned.file == path) + .ok_or_else(|| { + WorkbenchError::not_found(format!("no planned overlay owns {}", path.display())) + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum OriginalSourceSnapshot { + Present(Vec), + Absent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlannedSourceOutput { + original: OriginalSourceSnapshot, + replacement: Vec, +} + +#[derive(Default)] +struct SourceApplyPlan { + changed_entries: Vec, + overlay_changed: bool, + outputs: BTreeMap, + affected_files: BTreeMap, +} + +impl SourceApplyPlan { + fn affected_files_json(&self, _root: &Path) -> Vec { + self.affected_files + .iter() + .map(|(absolute_path, display_path)| { + json!({ + "path": display_path, + "absolute_path": absolute_path.display().to_string(), + }) + }) + .collect() + } +} + +fn apply_staged_source_edits( + modified_base: &mut CanonicalDeck, + overlay: &mut Overlay, + overlay_document: &mut OverlaySourceDocument, + edits: &[StagedWorkbenchEdit], + context: &SelectedTranslationContext, + base_file: &Path, + manifest_root: &Path, +) -> WorkbenchCoreResult { + let mut plan = SourceApplyPlan::default(); + if edits.is_empty() { + return Ok(plan); + } + + ensure_root_source_mutable("Workbench source edit", &context.base_source)?; + for include in &context.base_includes { + ensure_root_source_mutable("Workbench included source edit", include)?; + } + let raw_deck_yaml = fs::read_to_string(base_file) + .map_err(|error| format!("{}: {error}", base_file.display()))?; + let mut document = canonical_source_document(base_file, &raw_deck_yaml)?; + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let row_by_path = rows + .iter() + .map(|row| (row.path.clone(), row.clone())) + .collect::>(); + + for edit in edits { + if !edit.path.starts_with("notes.") || !edit.path.contains(".fields.") { + return Err(WorkbenchError::request(format!( + "invalid source note-field edit path {:?}", + edit.path + ))); + } + if edit.source.is_empty() { + return Err(WorkbenchError::request(format!( + "invalid empty source for {}", + edit.path + ))); + } + let Some(anchor_row) = row_by_path.get(&edit.path) else { + return Err(WorkbenchError::request(format!( + "invalid source edit path {:?}; choose an editable source note field", + edit.path + ))); + }; + if anchor_row.source != edit.source { + return Err(WorkbenchError::request(format!( + "invalid source {:?} for {}; expected {:?}", + edit.source, edit.path, anchor_row.source + ))); + } + let target_rows = match edit.scope { + SourceEditScope::Field => vec![anchor_row.clone()], + SourceEditScope::AllOccurrences => rows + .iter() + .filter(|row| row.source == edit.source) + .cloned() + .collect::>(), + }; + let target_path_set = target_rows + .iter() + .map(|row| row.path.clone()) + .collect::>(); + let old_source_remains = rows + .iter() + .any(|row| row.source == edit.source && !target_path_set.contains(row.path.as_str())); + + for row in &target_rows { + let (note_id, field_id) = note_field_path(&row.path)?; + let location = document + .set_scalar( + CanonicalScalarTarget::NoteField { + note_id: StableId::new(note_id).map_err(|error| error.to_string())?, + field_id: StableId::new(field_id).map_err(|error| error.to_string())?, + }, + &edit.source, + &edit.value, + ) + .map_err(|error| error.to_string())?; + set_deck_note_field(modified_base, &row.path, &edit.source, &edit.value)?; + let changed_path = match location { + EditLocation::Root => base_file.to_path_buf(), + EditLocation::Included(provenance) => PathBuf::from(provenance.source_name()), + }; + plan.affected_files.insert( + changed_path.clone(), + workspace_path(manifest_root, &changed_path), + ); + plan.changed_entries.push(json!({ + "mode": "source", + "path": row.path, + "source": edit.source, + "old": edit.source, + "new": edit.value, + "scope": edit.scope, + })); + } + + plan.overlay_changed |= apply_source_translation_impact( + overlay, + overlay_document, + edit, + &target_rows, + old_source_remains, + context, + &mut plan.changed_entries, + )?; + } + + collect_document_emission( + document.emit().map_err(|error| error.to_string())?, + &mut plan.outputs, + )?; + plan.outputs + .retain(|path, _| plan.affected_files.contains_key(path)); + Ok(plan) +} + +fn apply_source_translation_impact( + overlay: &mut Overlay, + document: &mut OverlaySourceDocument, + edit: &StagedWorkbenchEdit, + target_rows: &[MainFieldRow], + old_source_remains: bool, + context: &SelectedTranslationContext, + changed_entries: &mut Vec, +) -> WorkbenchCoreResult { + let mut changed = false; + if target_rows.is_empty() { + return Ok(false); + } + let use_global_impact = edit.scope == SourceEditScope::AllOccurrences + && !old_source_remains + && target_rows.iter().all(|row| { + matches!( + row.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) + }) + && target_rows.iter().all(|row| { + row.category != TranslationCoverageCategory::StaleTranslation + || context + .report + .entries + .iter() + .find(|entry| entry.path == row.path && entry.source == row.source) + .and_then(|entry| entry.context.as_deref()) + .is_none() + }) + && target_rows + .iter() + .map(target_text_for_source_impact) + .collect::>>() + .is_some_and(|targets| targets.len() <= 1); + let impact_rows = if use_global_impact { + vec![target_rows[0].clone()] + } else { + target_rows.to_vec() + }; + for row in impact_rows { + let Some(target) = target_text_for_source_impact(&row) else { + continue; + }; + let context_path = if use_global_impact { + None + } else { + Some(contextual_path_for_row(&row, &context.report.entries)) + }; + let impact = match edit.impact_action { + SourceImpactAction::StaleTranslation => SourceTranslationImpact::MarkStale { + target: target.clone(), + context: context_path.clone(), + }, + SourceImpactAction::MigrateKey => SourceTranslationImpact::MigrateKey { + target: target.clone(), + context: context_path.clone(), + }, + }; + document + .apply_source_translation_impact(&row.path, &edit.source, &edit.value, impact) + .map_err(|error| error.to_string())?; + changed_entries.push(json!({ + "mode": match edit.impact_action { + SourceImpactAction::StaleTranslation => "stale_translation", + SourceImpactAction::MigrateKey => "migrate_key", + }, + "path": row.path, + "old_source": edit.source, + "new_source": edit.value, + "target": target, + "context": context_path, + "impact_action": match edit.impact_action { + SourceImpactAction::StaleTranslation => "stale_translation", + SourceImpactAction::MigrateKey => "migrate_key", + }, + "available_impact_actions": ["stale_translation", "migrate_key"], + })); + changed = true; + } + if changed { + *overlay = document.resolved_overlay().clone(); + } + Ok(changed) +} + +fn target_text_for_source_impact(row: &MainFieldRow) -> Option { + match row.category { + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation => Some(row.translated.clone()), + _ => None, + } +} + +fn set_deck_note_field( + deck: &mut CanonicalDeck, + path: &str, + expected_source: &str, + value: &str, +) -> WorkbenchCoreResult<()> { + let (note_id, field_id) = note_field_path(path)?; + let note_id = + StableId::new(note_id).map_err(|error| WorkbenchError::request(error.to_string()))?; + let field_id = + StableId::new(field_id).map_err(|error| WorkbenchError::request(error.to_string()))?; + let note = deck.notes.get_mut(¬e_id).ok_or_else(|| { + WorkbenchError::request(format!( + "source edit path {path:?} is not in the canonical deck file" + )) + })?; + let current = note + .fields + .get(&field_id) + .and_then(FieldValue::as_scalar) + .unwrap_or_default(); + if current != expected_source { + return Err(WorkbenchError::request(format!( + "invalid source {:?} for {}; expected canonical deck value {:?}", + expected_source, path, current + ))); + } + note.fields + .insert(field_id, FieldValue::Scalar(value.to_owned())); + Ok(()) +} + +fn note_field_path(path: &str) -> WorkbenchCoreResult<(&str, &str)> { + let Some(rest) = path.strip_prefix("notes.") else { + return Err((format!("invalid note-field path {path:?}")).into()); + }; + let Some((note_id, field_id)) = rest.split_once(".fields.") else { + return Err((format!("invalid note-field path {path:?}")).into()); + }; + if note_id.is_empty() || field_id.is_empty() { + return Err((format!("invalid note-field path {path:?}")).into()); + } + Ok((note_id, field_id)) +} + +fn collect_document_emission( + emission: SourceDocumentEmission, + outputs: &mut BTreeMap, +) -> WorkbenchCoreResult<()> { + collect_document_source( + emission.root(), + emission.original_source(emission.root().provenance()), + outputs, + )?; + for source in emission.included() { + collect_document_source( + source, + emission.original_source(source.provenance()), + outputs, + )?; + } + Ok(()) +} + +fn collect_document_source( + source: &brain_brew_formats::source_document::SourceFile, + original: Option<&brain_brew_formats::source_document::SourceFile>, + outputs: &mut BTreeMap, +) -> WorkbenchCoreResult<()> { + let path = PathBuf::from(source.provenance().source_name()); + let output = PlannedSourceOutput { + original: original.map_or(OriginalSourceSnapshot::Absent, |original| { + OriginalSourceSnapshot::Present(original.text().as_bytes().to_vec()) + }), + replacement: source.text().as_bytes().to_vec(), + }; + if let Some(previous) = outputs.insert(path.clone(), output.clone()) + && previous != output + { + return Err((format!( + "conflicting Workbench outputs or original snapshots for {}", + path.display() + )) + .into()); + } + Ok(()) +} + +fn planned_workbench_outputs( + workspace_root: &Path, + outputs: BTreeMap, +) -> WorkbenchCoreResult> { + let canonical_root = fs::canonicalize(workspace_root) + .map_err(|error| format!("{}: {error}", workspace_root.display()))?; + outputs + .into_iter() + .map(|(path, output)| { + let absolute = if path.is_absolute() { + path + } else { + canonical_root.join(path) + }; + let canonical = match &output.original { + OriginalSourceSnapshot::Present(_) => { + let parent = absolute + .parent() + .ok_or_else(|| format!("{} has no parent", absolute.display()))?; + let canonical_parent = fs::canonicalize(parent) + .map_err(|error| format!("{}: {error}", parent.display()))?; + let name = absolute + .file_name() + .ok_or_else(|| format!("{} has no file name", absolute.display()))?; + canonical_parent.join(name) + } + OriginalSourceSnapshot::Absent => { + let mut ancestor = absolute.as_path(); + let mut suffix = Vec::new(); + while !ancestor.exists() { + suffix.push( + ancestor + .file_name() + .ok_or_else(|| { + format!("{} has no existing ancestor", absolute.display()) + })? + .to_os_string(), + ); + ancestor = ancestor.parent().ok_or_else(|| { + format!("{} has no existing ancestor", absolute.display()) + })?; + } + let mut resolved = fs::canonicalize(ancestor) + .map_err(|error| format!("{}: {error}", ancestor.display()))?; + for component in suffix.iter().rev() { + resolved.push(component); + } + resolved + } + }; + if !canonical.starts_with(&canonical_root) { + return Err(format!( + "Workbench mutation target {} is outside root workspace {}", + canonical.display(), + canonical_root.display() + )); + } + match output.original { + OriginalSourceSnapshot::Present(original) => PlannedWorkspaceFile::validated( + canonical, + original, + output.replacement, + validate_utf8_workbench_output, + ), + OriginalSourceSnapshot::Absent => PlannedWorkspaceFile::validated_new( + canonical, + output.replacement, + validate_utf8_workbench_output, + ), + } + }) + .collect::, String>>() + .map_err(WorkbenchError::adapter) +} + +fn validate_utf8_workbench_output(bytes: &[u8]) -> Result<(), String> { + std::str::from_utf8(bytes) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn content_diagnostics(report: &ContentValidationReport) -> Vec { + report + .errors + .iter() + .map(|error| DomainDiagnostic { + code: match error.kind { + ContentKind::HtmlFragment => "invalid_html_content", + ContentKind::Css => "invalid_css_content", + }, + category: DiagnosticCategory::Validation, + path: error.path.parse().ok(), + address: error.path.clone(), + overlay_id: None, + source_id: None, + intent: None, + entity_kind: None, + expected: None, + actual: None, + first_conflict_participant: None, + current_conflict_participant: None, + original_removal: None, + field_graph_error: None, + children: Vec::new(), + message: match error.line { + Some(line) => format!("line {line}: {}", error.message), + None => error.message.clone(), + }, + }) + .collect() +} + +fn validate_complete_workbench_result( + modified_base: &CanonicalDeck, + overlays: &BTreeMap, + contexts: &[SelectedTranslationContext], +) -> WorkbenchCoreResult> { + canonical_yaml::to_string(modified_base).map_err(|error| error.to_string())?; + if !modified_base.media.is_empty() { + media::validate_references(modified_base).map_err(|error| error.to_string())?; + } + for (display, document, overlay, changed) in overlays.values() { + if !changed { + continue; + } + document + .emit() + .map_err(|error| format!("invalid generated translation overlay {display}: {error}"))?; + canonical_yaml::overlay_to_string(overlay) + .map_err(|error| format!("invalid generated translation overlay {display}: {error}"))?; + } + let mut diagnostics = Vec::new(); + let mut validated = BTreeSet::new(); + for context in contexts { + if !validated.insert(context.selection.target_id.clone()) { + continue; + } + let mut deck = modified_base.clone(); + for (planned, original) in &context.plan_overlays { + let overlay = overlays + .get(&planned.file) + .filter(|(_, _, _, changed)| *changed) + .map(|(_, _, overlay, _)| overlay) + .unwrap_or(original); + let effective_overlay = if overlay.translations.is_some() { + sanitize_lenient_translation_overlay(&deck, overlay).map_err(|report| { + WorkbenchError::field_graph( + format!("failed to validate translation overlay {}", planned.id), + report, + ) + })? + } else { + overlay.clone() + }; + deck = deck + .compose(std::slice::from_ref(&effective_overlay)) + .map_err(|report| { + WorkbenchError::compose( + format!( + "failed to validate complete Workbench target {} at overlay {}", + context.selection.target_id, planned.id + ), + report, + ) + })?; + } + let rendered = deck.render_variables().map_err(|report| { + WorkbenchError::render( + format!( + "failed to render complete Workbench target {}", + context.selection.target_id + ), + report, + ) + })?; + diagnostics.extend(content_diagnostics(&validate_deck_content(&rendered))); + if !deck.media.is_empty() { + media::validate_references(&deck).map_err(|error| { + format!( + "media validation failed for complete Workbench target {}: {error}", + context.selection.target_id + ) + })?; + } + } + Ok(diagnostics) +} + +fn push_unique_affected_file( + files: &mut Vec, + root: &Path, + absolute_path: &Path, + display_path: Option<&str>, +) { + let absolute = absolute_path.display().to_string(); + if files + .iter() + .any(|file| file["absolute_path"].as_str() == Some(absolute.as_str())) + { + return; + } + files.push(json!({ + "path": display_path + .map(str::to_owned) + .unwrap_or_else(|| workspace_path(root, absolute_path)), + "absolute_path": absolute, + })); +} + +fn editable_sources_by_path(context: &SelectedTranslationContext) -> BTreeMap { + let mut sources = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ) + .into_iter() + .filter(|row| !row.structural) + .map(|row| (row.path, row.source)) + .collect::>(); + for row in source_string_rows(context) + .into_iter() + .filter(|row| !row.structural) + { + sources.entry(row.path).or_insert(row.source); + } + for entry in &context.report.entries { + if entry.source.is_empty() + || path_matches_any(&context.selection.metadata_exclude_paths, &entry.path) + || metadata_category_for_path(&context.selection.metadata_categories, &entry.path) + .is_none() + { + continue; + } + sources + .entry(entry.path.clone()) + .or_insert_with(|| entry.source.clone()); + } + sources +} + +fn contextual_path_for_edit( + context: &SelectedTranslationContext, + edit: &StagedWorkbenchEdit, +) -> WorkbenchCoreResult { + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let source_rows = if rows + .iter() + .any(|row| row.path == edit.path && row.source == edit.source && !row.structural) + { + rows + } else { + source_string_rows(context) + }; + let row = source_rows + .iter() + .find(|row| row.path == edit.path && row.source == edit.source && !row.structural); + if let Some(row) = row { + if let Some(context_path) = &edit.context_path { + if context_path == &row.path + || row + .path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) + { + return Ok(context_path.clone()); + } + return Err((format!( + "invalid contextual edit context {:?} for {}", + context_path, edit.path + )) + .into()); + } + return Ok(contextual_path_for_row(row, &context.report.entries)); + } + + if !path_matches_any(&context.selection.metadata_exclude_paths, &edit.path) + && metadata_category_for_path(&context.selection.metadata_categories, &edit.path).is_some() + && context + .report + .entries + .iter() + .any(|entry| entry.path == edit.path && entry.source == edit.source) + { + if let Some(context_path) = &edit.context_path { + if context_path == &edit.path + || edit + .path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) + { + return Ok(context_path.clone()); + } + return Err((format!( + "invalid contextual edit context {:?} for {}", + context_path, edit.path + )) + .into()); + } + return Ok(edit.path.clone()); + } + + Err(WorkbenchError::request(format!( + "invalid contextual edit path {:?}", + edit.path + ))) +} + +fn apply_staged_edits_to_overlay( + overlay: &mut Overlay, + document: &mut OverlaySourceDocument, + edits: &[StagedWorkbenchEdit], + context: &SelectedTranslationContext, +) -> WorkbenchCoreResult> { + let editable_sources = editable_sources_by_path(context); + let mut changed = Vec::new(); + for edit in edits { + if edit.source.is_empty() { + return Err(WorkbenchError::request(format!( + "invalid empty source for {}", + edit.path + ))); + } + let Some(expected_source) = editable_sources.get(edit.path.as_str()) else { + return Err(WorkbenchError::request(format!( + "invalid edit path {:?}; choose an editable translation source in the selected target", + edit.path + ))); + }; + if expected_source != &edit.source { + return Err(WorkbenchError::request(format!( + "invalid source {:?} for {}; expected {:?}", + edit.source, edit.path, expected_source + ))); + } + let translations = overlay.translations.as_ref(); + match edit.mode { + EditMode::Direct => { + let old = translations + .and_then(|dictionary| dictionary.direct.get(&edit.source)) + .cloned(); + document + .set_translation_decision( + &edit.path, + &edit.source, + TranslationDecision::Direct(edit.value.clone()), + ) + .map_err(|error| error.to_string())?; + changed.push(json!({ + "mode": "direct", + "path": edit.path, + "source": edit.source, + "old": old, + "new": edit.value, + })); + } + EditMode::Contextual => { + let context_path = contextual_path_for_edit(context, edit)?; + let old = translations + .and_then(|dictionary| dictionary.contextual.get(&context_path)) + .and_then(|replacements| replacements.get(&edit.source)) + .cloned(); + document + .set_translation_decision( + &edit.path, + &edit.source, + TranslationDecision::Contextual { + context: context_path.clone(), + target: edit.value.clone(), + }, + ) + .map_err(|error| error.to_string())?; + changed.push(json!({ + "mode": "contextual", + "path": context_path, + "field_path": edit.path, + "source": edit.source, + "old": old, + "new": edit.value, + })); + } + EditMode::NoChange => { + let existed = translations + .is_some_and(|dictionary| dictionary.no_change.contains(&edit.source)); + document + .set_translation_decision( + &edit.path, + &edit.source, + TranslationDecision::NoChange, + ) + .map_err(|error| error.to_string())?; + changed.push(json!({ + "mode": "no_change", + "path": edit.path, + "source": edit.source, + "old": if existed { json!(edit.source) } else { Value::Null }, + "new": edit.source, + })); + } + } + *overlay = document.resolved_overlay().clone(); + } + Ok(changed) +} + +fn validate_modified_base_and_overlay( + context: &SelectedTranslationContext, + modified_base: &CanonicalDeck, + modified_overlay: &Overlay, +) -> WorkbenchCoreResult { + let updated_context = + context_with_modified_base_and_overlay(context, modified_base.clone(), modified_overlay)?; + let rendered = updated_context + .target_deck + .render_variables() + .map_err(|report| { + WorkbenchError::render( + format!( + "failed to render modified Workbench target {}", + updated_context.selection.target_id + ), + report, + ) + })?; + Ok(ApplyValidation { + diagnostics: content_diagnostics(&validate_deck_content(&rendered)), + }) +} + +fn context_with_modified_base_and_overlay( + context: &SelectedTranslationContext, + modified_base: CanonicalDeck, + modified_overlay: &Overlay, +) -> WorkbenchCoreResult { + let mut current = modified_base.clone(); + let mut selected_source_deck = None; + let mut selected_report = None; + let mut effective_overlays = Vec::new(); + for (planned, overlay) in &context.plan_overlays { + let active_overlay = if planned.id == context.selection.overlay_id { + modified_overlay + } else { + overlay + }; + if planned.id == context.selection.overlay_id { + selected_source_deck = Some(current.clone()); + selected_report = Some(current.translation_coverage(active_overlay).map_err( + |report| { + WorkbenchError::field_graph( + "failed to resolve translation source fields", + report, + ) + }, + )?); + } + let effective_overlay = if active_overlay.translations.is_some() { + sanitize_lenient_translation_overlay(¤t, active_overlay).map_err(|report| { + WorkbenchError::field_graph( + format!("failed to resolve translation overlay {}", planned.id), + report, + ) + })? + } else { + active_overlay.clone() + }; + current = current + .compose(std::slice::from_ref(&effective_overlay)) + .map_err(|report| { + WorkbenchError::compose(format!("failed to compose overlay {}", planned.id), report) + })?; + effective_overlays.push(effective_overlay); + } + let final_report = modified_base + .translation_stack_coverage(&effective_overlays) + .map_err(|report| { + WorkbenchError::compose( + format!( + "failed to resolve final translation coverage for target {}", + context.selection.target_id + ), + report, + ) + })?; + let Some(source_deck) = selected_source_deck else { + return Err((format!( + "target {} does not include translation overlay {}", + context.selection.target_id, context.selection.overlay_id + )) + .into()); + }; + let Some(report) = selected_report else { + return Err((format!( + "overlay {} is not a translation overlay", + context.selection.overlay_id + )) + .into()); + }; + Ok(SelectedTranslationContext { + selection: context.selection.clone(), + base_deck: modified_base, + base_source: context.base_source.clone(), + base_includes: context.base_includes.clone(), + plan_overlays: context.plan_overlays.clone(), + media_declarations: context.media_declarations.clone(), + source_deck, + target_deck: current, + report, + final_report, + }) +} + +fn languages_json(manifest: &FederatedDeckManifest) -> Value { + let languages = manifest + .languages + .iter() + .map(|(code, language)| (code.clone(), language_json(language))) + .collect::>(); + Value::Object(languages) +} + +fn language_json(language: &LanguageManifestEntry) -> Value { + json!({ + "display_name": language.display_name, + "source": language.source, + "translation_overlays": language.translation_overlays, + "primary_target": language.primary_target, + "targets": language.targets, + }) +} + +fn target_labels_json(manifest: &FederatedDeckManifest) -> Value { + let mut labels = BTreeMap::>::new(); + for (language, entry) in &manifest.languages { + for (label, target) in &entry.targets { + labels.entry(target.clone()).or_default().push(json!({ + "language": language, + "label": label, + })); + } + } + json!(labels) +} + +fn targets_json(manifest: &FederatedDeckManifest) -> Value { + let targets = manifest + .targets + .iter() + .map(|(name, target)| { + ( + name.clone(), + json!({ + "extends": target.extends, + "overlays": target.overlays, + }), + ) + }) + .collect::>(); + Value::Object(targets) +} + +fn file_fingerprints( + manifest_path: &Path, + root: &Path, + _manifest: &FederatedDeckManifest, +) -> WorkbenchCoreResult> { + let registry = ManifestRegistry::load(manifest_path, &[], &[])?; + let mut entries = Vec::new(); + for loaded in registry.manifests() { + entries.push(file_fingerprint( + root, + &loaded.path, + loaded + .identity + .as_ref() + .map(|identity| identity.id.as_str()), + &loaded.root, + loaded.discovery.ownership_name(), + )?); + } + for source in registry.registered_sources()? { + entries.push(file_fingerprint( + root, + &source.path, + source.package.as_ref().map(|identity| identity.id.as_str()), + &source.package_root, + source.registry_source.ownership_name(), + )?); + } + entries.sort_by(|left, right| left["path"].as_str().cmp(&right["path"].as_str())); + entries.dedup_by(|left, right| left["path"] == right["path"]); + Ok(entries) +} + +fn authorized_manifest_source_files( + manifest_path: &Path, + _root: &Path, + _manifest: &FederatedDeckManifest, +) -> WorkbenchCoreResult> { + let registry = ManifestRegistry::load(manifest_path, &[], &[])?; + let mut files = registry + .manifests() + .iter() + .map(|loaded| loaded.path.clone()) + .collect::>(); + files.extend( + registry + .registered_sources()? + .into_iter() + .map(|source| source.path), + ); + files.sort(); + files.dedup(); + Ok(files) +} + +fn file_fingerprint( + root: &Path, + path: &Path, + package: Option<&str>, + package_root: &Path, + source_kind: &str, +) -> WorkbenchCoreResult { + let bytes = fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; + let hash = Sha256::digest(&bytes); + Ok(json!({ + "path": workspace_path(root, path), + "sha256": format!("{hash:x}"), + "package": package, + "package_root": package_root.display().to_string(), + "source_kind": source_kind, + })) +} + +fn workspace_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn open_browser(url: &str) { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", "", url]); + command + }; + + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(url); + command + }; + + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(url); + command + }; + + let _ = command.spawn(); +} + +#[cfg(test)] +mod transaction_snapshot_tests { + use super::*; + + fn assert_t0_conflict( + root: &Path, + target: &Path, + outputs: BTreeMap, + external: &[u8], + ) { + fs::write(target, external).unwrap(); + let writes = planned_workbench_outputs(root, outputs).unwrap(); + let error = commit_workspace_files(root, writes).unwrap_err(); + assert!( + error.contains("does not match expected state"), + "unexpected conflict: {error}" + ); + assert_eq!(fs::read(target).unwrap(), external); + } + + #[test] + fn canonical_source_snapshot_is_not_rebased_during_transaction_planning() { + let workspace = tempfile::tempdir().unwrap(); + let path = workspace.path().join("deck.yaml"); + let original = include_str!("../../../../fixtures/ug-style/deck.yaml"); + fs::write(&path, original).unwrap(); + let mut document = canonical_source_document(&path, original).unwrap(); + document + .set_scalar( + CanonicalScalarTarget::NoteField { + note_id: StableId::new("note.finland").unwrap(), + field_id: StableId::new("field.country").unwrap(), + }, + "Finland", + "Computed replacement", + ) + .unwrap(); + let mut outputs = BTreeMap::new(); + collect_document_emission(document.emit().unwrap(), &mut outputs).unwrap(); + + assert_t0_conflict( + workspace.path(), + &path, + outputs, + b"external canonical edit\n", + ); + } + + #[test] + fn included_source_snapshot_uses_exact_loader_bytes() { + let workspace = tempfile::tempdir().unwrap(); + let path = workspace.path().join("deck.yaml"); + let include = workspace.path().join("description.md"); + let original = "deck:\n id: deck.snapshot\n name: Snapshot\n description: !include description.md\n adapter_ids: {}\nnote_types: {}\nnotes: {}\nmedia: {}\ntombstones: []\n"; + fs::write(&path, original).unwrap(); + fs::write(&include, "loader bytes\n").unwrap(); + let mut document = canonical_source_document(&path, original).unwrap(); + document + .set_scalar( + CanonicalScalarTarget::DeckDescription, + "loader bytes\n", + "computed include replacement\n", + ) + .unwrap(); + let mut outputs = BTreeMap::new(); + collect_document_emission(document.emit().unwrap(), &mut outputs).unwrap(); + outputs.retain(|output_path, _| output_path == &include); + + assert_t0_conflict( + workspace.path(), + &include, + outputs, + b"external include edit\n", + ); + } + + #[test] + fn translation_overlay_snapshot_is_not_rebased_during_transaction_planning() { + let workspace = tempfile::tempdir().unwrap(); + let path = workspace.path().join("da.yaml"); + let original = "id: overlay.translation.da\nkind: translation\ntranslations:\n direct:\n Finland: Finlande\n"; + fs::write(&path, original).unwrap(); + let mut document = overlay_source_document(&path, original).unwrap(); + document + .set_translation_decision( + "notes.note.finland.fields.field.country", + "Finland", + TranslationDecision::Direct("Suomi".to_owned()), + ) + .unwrap(); + let mut outputs = BTreeMap::new(); + collect_document_emission(document.emit().unwrap(), &mut outputs).unwrap(); + + assert_t0_conflict( + workspace.path(), + &path, + outputs, + b"external translation edit\n", + ); + } + + #[test] + fn new_language_manifest_and_source_expectations_are_not_rebased() { + let manifest_workspace = tempfile::tempdir().unwrap(); + let manifest_path = manifest_workspace.path().join("brainbrew.yaml"); + let original_manifest = b"package: old\n".to_vec(); + fs::write(&manifest_path, &original_manifest).unwrap(); + let manifest_outputs = BTreeMap::from([( + manifest_path.clone(), + PlannedSourceOutput { + original: OriginalSourceSnapshot::Present(original_manifest), + replacement: b"package: computed\n".to_vec(), + }, + )]); + assert_t0_conflict( + manifest_workspace.path(), + &manifest_path, + manifest_outputs, + b"package: external\n", + ); + + let source_workspace = tempfile::tempdir().unwrap(); + let source_path = source_workspace.path().join("overlays/new.yaml"); + fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + let source_outputs = BTreeMap::from([( + source_path.clone(), + PlannedSourceOutput { + original: OriginalSourceSnapshot::Absent, + replacement: b"id: overlay.translation.new\nkind: translation\n".to_vec(), + }, + )]); + assert_t0_conflict( + source_workspace.path(), + &source_path, + source_outputs, + b"external new-language source\n", + ); + } +} diff --git a/crates/brain-brew-cli/src/fetch_policy.rs b/crates/brain-brew-cli/src/fetch_policy.rs new file mode 100644 index 0000000..7101cab --- /dev/null +++ b/crates/brain-brew-cli/src/fetch_policy.rs @@ -0,0 +1,577 @@ +//! Typed, injectable resource policy for remote package fetches and archives. +//! +//! Production network requests are HTTPS-only. Tests may opt into the private +//! local-HTTP adapter; lock source YAML can never select that adapter. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use tempfile::NamedTempFile; +use url::Url; + +const USER_AGENT: &str = concat!("brainbrew/", env!("CARGO_PKG_VERSION")); + +/// One conservative policy shared by GitHub API and archive source adapters. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct FetchPolicy { + pub(crate) connect_timeout: Duration, + pub(crate) read_timeout: Duration, + pub(crate) total_timeout: Duration, + pub(crate) max_redirects: u32, + pub(crate) max_download_bytes: u64, + pub(crate) max_json_bytes: u64, + pub(crate) max_decompressed_tar_bytes: u64, + pub(crate) max_regular_file_bytes: u64, + pub(crate) max_archive_entries: u64, + pub(crate) max_expanded_regular_bytes: u64, + pub(crate) max_archive_path_bytes: usize, + pub(crate) max_archive_path_depth: usize, + pub(crate) max_archive_metadata_bytes: u64, + pub(crate) max_expansion_ratio: u64, +} + +impl Default for FetchPolicy { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(10), + read_timeout: Duration::from_secs(30), + total_timeout: Duration::from_secs(120), + max_redirects: 5, + max_download_bytes: 64 * 1024 * 1024, + max_json_bytes: 1024 * 1024, + max_decompressed_tar_bytes: 512 * 1024 * 1024, + max_regular_file_bytes: 64 * 1024 * 1024, + max_archive_entries: 20_000, + max_expanded_regular_bytes: 256 * 1024 * 1024, + max_archive_path_bytes: 1024, + max_archive_path_depth: 32, + max_archive_metadata_bytes: 64 * 1024, + max_expansion_ratio: 200, + } + } +} + +#[derive(Debug)] +pub(crate) struct DownloadedFile { + file: NamedTempFile, + pub(crate) bytes: u64, + pub(crate) started: Instant, +} + +impl DownloadedFile { + pub(crate) fn path(&self) -> &Path { + self.file.path() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TransportMode { + HttpsOnly, + #[cfg(test)] + LocalHttp, +} + +pub(crate) fn fetch_to_temp( + source: &str, + policy: &FetchPolicy, + max_bytes: u64, + accept: Option<&str>, +) -> Result { + fetch_to_temp_with_mode(source, policy, max_bytes, accept, TransportMode::HttpsOnly) +} + +fn fetch_to_temp_with_mode( + source: &str, + policy: &FetchPolicy, + max_bytes: u64, + accept: Option<&str>, + mode: TransportMode, +) -> Result { + let started = Instant::now(); + if let Some(path) = local_source_path(source)? { + return copy_local_to_temp(source, &path, policy, started, max_bytes); + } + + let mut url = Url::parse(source) + .map_err(|error| format!("package source {source:?}: invalid URL: {error}"))?; + validate_network_url(source, &url, mode)?; + let mut redirects = 0_u32; + + loop { + let elapsed = started.elapsed(); + let remaining = policy.total_timeout.checked_sub(elapsed).ok_or_else(|| { + budget_error( + source, + "total_timeout", + format_duration(elapsed), + format_duration(policy.total_timeout), + ) + })?; + let agent = ureq::builder() + .redirects(0) + .redirect_auth_headers(ureq::RedirectAuthHeaders::Never) + .timeout_connect(policy.connect_timeout.min(remaining)) + .timeout_read(policy.read_timeout.min(remaining)) + .timeout_write(remaining) + .timeout(remaining) + .user_agent(USER_AGENT) + .build(); + let mut request = agent.get(url.as_str()).set("Accept-Encoding", "identity"); + if let Some(accept) = accept { + request = request.set("Accept", accept); + } + let response = match request.call() { + Ok(response) => response, + Err(ureq::Error::Status(_, response)) => response, + Err(error) => { + let elapsed = started.elapsed(); + if error.to_string().to_ascii_lowercase().contains("timed out") { + let (budget, limit) = if elapsed >= policy.total_timeout { + ("total_timeout", policy.total_timeout) + } else if elapsed < policy.read_timeout { + ("connect_timeout", policy.connect_timeout) + } else { + ("read_timeout", policy.read_timeout) + }; + return Err(format!( + "{}; transport error: {error}", + budget_error( + source, + budget, + format_duration(elapsed), + format_duration(limit) + ) + )); + } + return Err(format!( + "package source {source:?}: transport error: {error}" + )); + } + }; + + if matches!(response.status(), 301 | 302 | 303 | 307 | 308) { + redirects += 1; + if redirects > policy.max_redirects { + return Err(budget_error( + source, + "redirect_count", + redirects, + policy.max_redirects, + )); + } + let location = response.header("Location").ok_or_else(|| { + format!( + "package source {source:?}: redirect {} from {} has no Location header", + response.status(), + url + ) + })?; + let next = url.join(location).map_err(|error| { + format!( + "package source {source:?}: invalid redirect Location {location:?}: {error}" + ) + })?; + validate_network_url(source, &next, mode)?; + // No credentials are accepted in URLs, and RedirectAuthHeaders::Never + // strips all credential headers even for same-host redirects. HTTPS + // cross-host redirects are intentionally permitted for CDNs. + url = next; + continue; + } + + if !(200..300).contains(&response.status()) { + return Err(format!( + "package source {source:?}: fetch of {url} returned HTTP {} {}", + response.status(), + response.status_text() + )); + } + validate_network_url( + source, + &Url::parse(response.get_url()).map_err(|error| { + format!("package source {source:?}: invalid final response URL: {error}") + })?, + mode, + )?; + check_content_length(source, response.header("Content-Length"), max_bytes)?; + return stream_response_to_temp(source, response.into_reader(), policy, started, max_bytes); + } +} + +fn validate_network_url(source: &str, url: &Url, mode: TransportMode) -> Result<(), String> { + let allow_http = match mode { + TransportMode::HttpsOnly => false, + #[cfg(test)] + TransportMode::LocalHttp => true, + }; + let allowed = url.scheme() == "https" || (url.scheme() == "http" && allow_http); + if !allowed { + return Err(format!( + "package source {source:?}: transport_scheme budget rejected scheme {:?}; current={}, limit=https", + url.scheme(), + url.scheme() + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(format!( + "package source {source:?}: URL credentials are forbidden and are never forwarded across redirects" + )); + } + Ok(()) +} + +fn check_content_length(source: &str, value: Option<&str>, max_bytes: u64) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + let length = value.parse::().map_err(|_| { + format!("package source {source:?}: invalid Content-Length header {value:?}") + })?; + if length > max_bytes { + return Err(budget_error( + source, + "compressed_download_bytes", + length, + max_bytes, + )); + } + Ok(()) +} + +fn stream_response_to_temp( + source: &str, + mut reader: impl Read, + policy: &FetchPolicy, + started: Instant, + max_bytes: u64, +) -> Result { + let mut file = + NamedTempFile::new().map_err(|error| format!("package source {source:?}: {error}"))?; + let mut total = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_total_deadline(source, policy, started)?; + let read_started = Instant::now(); + let count = reader.read(&mut buffer).map_err(|error| { + let total_elapsed = started.elapsed(); + let (budget, current, limit) = if total_elapsed >= policy.total_timeout { + ( + "total_timeout", + format_duration(total_elapsed), + format_duration(policy.total_timeout), + ) + } else { + ( + "read_timeout", + format_duration(read_started.elapsed()), + format_duration(policy.read_timeout), + ) + }; + budget_error(source, budget, current, limit) + &format!("; read error: {error}") + })?; + let read_elapsed = read_started.elapsed(); + if read_elapsed > policy.read_timeout { + return Err(budget_error( + source, + "read_timeout", + format_duration(read_elapsed), + format_duration(policy.read_timeout), + )); + } + if count == 0 { + break; + } + total = total.saturating_add(count as u64); + if total > max_bytes { + return Err(budget_error( + source, + "compressed_download_bytes", + total, + max_bytes, + )); + } + file.write_all(&buffer[..count]).map_err(|error| { + format!("package source {source:?}: temporary download write failed: {error}") + })?; + check_total_deadline(source, policy, started)?; + } + file.as_file_mut().sync_all().map_err(|error| { + format!("package source {source:?}: temporary download sync failed: {error}") + })?; + Ok(DownloadedFile { + file, + bytes: total, + started, + }) +} + +fn copy_local_to_temp( + source: &str, + path: &Path, + policy: &FetchPolicy, + started: Instant, + max_bytes: u64, +) -> Result { + let metadata = + fs::metadata(path).map_err(|error| format!("package source {source:?}: {error}"))?; + if metadata.len() > max_bytes { + return Err(budget_error( + source, + "compressed_download_bytes", + metadata.len(), + max_bytes, + )); + } + let input = + fs::File::open(path).map_err(|error| format!("package source {source:?}: {error}"))?; + stream_response_to_temp(source, input, policy, started, max_bytes) +} + +fn local_source_path(source: &str) -> Result, String> { + if source.starts_with("file://") { + let url = Url::parse(source) + .map_err(|error| format!("package source {source:?}: invalid file URL: {error}"))?; + return url + .to_file_path() + .map(Some) + .map_err(|()| format!("package source {source:?}: file URL is not a local path")); + } + let path = Path::new(source); + Ok(path.exists().then(|| path.to_path_buf())) +} + +pub(crate) fn check_total_deadline( + source: &str, + policy: &FetchPolicy, + started: Instant, +) -> Result<(), String> { + let elapsed = started.elapsed(); + if elapsed > policy.total_timeout { + Err(budget_error( + source, + "total_timeout", + format_duration(elapsed), + format_duration(policy.total_timeout), + )) + } else { + Ok(()) + } +} + +pub(crate) fn budget_error( + source: &str, + budget: &str, + current: impl std::fmt::Display, + limit: impl std::fmt::Display, +) -> String { + format!( + "package source {source:?}: {budget} budget exhausted: current={current}, limit={limit}" + ) +} + +fn format_duration(duration: Duration) -> String { + format!("{}ms", duration.as_millis()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + use std::thread; + use std::time::Duration; + + #[test] + fn documented_defaults_cover_connect_read_total_and_resource_budgets() { + let policy = FetchPolicy::default(); + assert_eq!(policy.connect_timeout, Duration::from_secs(10)); + assert_eq!(policy.read_timeout, Duration::from_secs(30)); + assert_eq!(policy.total_timeout, Duration::from_secs(120)); + assert_eq!(policy.max_redirects, 5); + assert_eq!(policy.max_download_bytes, 64 * 1024 * 1024); + assert_eq!(policy.max_archive_entries, 20_000); + assert_eq!(policy.max_expansion_ratio, 200); + } + + #[test] + fn production_transport_rejects_http_and_url_credentials() { + let policy = FetchPolicy::default(); + let error = fetch_to_temp("http://example.test/archive.tar", &policy, 100, None) + .expect_err("HTTP is rejected before connecting"); + assert!(error.contains("transport_scheme"), "{error}"); + let error = fetch_to_temp( + "https://user:secret@example.test/archive.tar", + &policy, + 100, + None, + ) + .expect_err("credentials are rejected before connecting"); + assert!(error.contains("credentials are forbidden"), "{error}"); + } + + #[test] + fn local_http_adapter_bounds_redirects_and_rejects_downgrade() { + let (base, server_handle) = server(vec![ + "HTTP/1.1 302 Found\r\nLocation: /again\r\nContent-Length: 0\r\n\r\n".into(), + "HTTP/1.1 302 Found\r\nLocation: /again\r\nContent-Length: 0\r\n\r\n".into(), + ]); + let policy = FetchPolicy { + max_redirects: 1, + ..FetchPolicy::default() + }; + let error = fetch_to_temp_with_mode(&base, &policy, 100, None, TransportMode::LocalHttp) + .expect_err("redirect limit is enforced"); + assert!(error.contains("redirect_count"), "{error}"); + server_handle.join().unwrap(); + + let (base, server_handle) = server(vec![ + "HTTP/1.1 302 Found\r\nLocation: ftp://example.test/file\r\nContent-Length: 0\r\n\r\n" + .into(), + ]); + let error = fetch_to_temp_with_mode( + &base, + &FetchPolicy::default(), + 100, + None, + TransportMode::LocalHttp, + ) + .expect_err("unsupported redirect scheme is rejected"); + assert!(error.contains("transport_scheme"), "{error}"); + server_handle.join().unwrap(); + } + + #[test] + fn local_http_adapter_enforces_read_and_total_deadlines() { + let (base, server_handle) = delayed_body_server(Duration::from_millis(80), 1, 1); + let policy = FetchPolicy { + read_timeout: Duration::from_millis(20), + total_timeout: Duration::from_secs(1), + ..FetchPolicy::default() + }; + let error = fetch_to_temp_with_mode(&base, &policy, 100, None, TransportMode::LocalHttp) + .expect_err("slow body exceeds per-read timeout"); + assert!(error.contains("read_timeout"), "{error}"); + server_handle.join().unwrap(); + + let (base, server_handle) = delayed_body_server(Duration::from_millis(15), 3, 1); + let policy = FetchPolicy { + read_timeout: Duration::from_millis(100), + total_timeout: Duration::from_millis(25), + ..FetchPolicy::default() + }; + let error = fetch_to_temp_with_mode(&base, &policy, 100, None, TransportMode::LocalHttp) + .expect_err("stream exceeds monotonic total deadline"); + assert!(error.contains("total_timeout"), "{error}"); + server_handle.join().unwrap(); + } + + #[test] + fn local_http_adapter_allows_bounded_cross_host_redirect_without_credentials() { + let (target, target_handle) = server(vec![ + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".into(), + ]); + let target = target.replace("127.0.0.1", "localhost"); + let (source, redirect_handle) = server(vec![format!( + "HTTP/1.1 302 Found\r\nLocation: {target}\r\nContent-Length: 0\r\n\r\n" + )]); + let download = fetch_to_temp_with_mode( + &source, + &FetchPolicy::default(), + 100, + None, + TransportMode::LocalHttp, + ) + .expect("cross-host CDN-style redirect is allowed by policy"); + assert_eq!(fs::read(download.path()).unwrap(), b"ok"); + redirect_handle.join().unwrap(); + target_handle.join().unwrap(); + } + + #[test] + fn local_http_adapter_rejects_length_and_streaming_overflow() { + let (base, server_handle) = server(vec![ + "HTTP/1.1 200 OK\r\nContent-Length: 101\r\n\r\n".into(), + ]); + let error = fetch_to_temp_with_mode( + &base, + &FetchPolicy::default(), + 100, + None, + TransportMode::LocalHttp, + ) + .expect_err("large declared length is rejected"); + assert!(error.contains("current=101, limit=100"), "{error}"); + server_handle.join().unwrap(); + + let (base, server_handle) = server(vec![ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n40\r\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\n40\r\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\r\n0\r\n\r\n".into(), + ]); + let error = fetch_to_temp_with_mode( + &base, + &FetchPolicy::default(), + 100, + None, + TransportMode::LocalHttp, + ) + .expect_err("chunked body is bounded while streaming"); + assert!(error.contains("compressed_download_bytes"), "{error}"); + server_handle.join().unwrap(); + } + + fn delayed_body_server( + delay: Duration, + chunks: usize, + chunk_bytes: usize, + ) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + request.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + } + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + chunks * chunk_bytes + ) + .unwrap(); + stream.flush().unwrap(); + for _ in 0..chunks { + thread::sleep(delay); + if stream.write_all(&vec![b'x'; chunk_bytes]).is_err() { + break; + } + let _ = stream.flush(); + } + }); + (format!("http://{address}/slow"), handle) + } + + fn server(responses: Vec) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + request.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + } + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{address}/start"), handle) + } +} diff --git a/crates/brain-brew-cli/src/help.rs b/crates/brain-brew-cli/src/help.rs new file mode 100644 index 0000000..10b758d --- /dev/null +++ b/crates/brain-brew-cli/src/help.rs @@ -0,0 +1,89 @@ +pub(crate) fn general() -> String { + format!( + concat!( + "Brain Brew {}\n", + "Local-first deck federation tooling for Anki-compatible decks.\n\n", + "Usage:\n", + " brainbrew [options]\n\n", + "Commands:\n", + " fmt Format deck, overlay, manifest, or lock YAML in place\n", + " validate Validate a deck file or manifest target\n", + " compose Compose a base deck plus overlays into resolved CanonicalDeck YAML\n", + " export Export a resolved deck to an adapter format, currently CrowdAnki\n", + " import Bootstrap a new Canonical Deck workspace from CrowdAnki\n", + " lock Update or verify locked federated package inputs\n", + " media Verify and refresh declared media asset hashes\n", + " targets List manifest targets\n", + " translations Report/apply translation coverage (aliases: translate, translation)\n", + " workbench Serve the local Deck Workbench browser UI and JSON API\n", + " verify Run manifest formatting, composition, validation, media, and golden checks\n", + " explain Explain a manifest target and its overlay stack\n", + " diff Compare decks semantically, or emit an overlay draft\n\n", + "Examples:\n", + " brainbrew targets --manifest brainbrew.yaml\n", + " brainbrew validate --manifest brainbrew.yaml --target da-standard\n", + " brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml\n", + " brainbrew compose --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n", + " brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography\n", + " brainbrew media hash --manifest brainbrew.yaml --all-targets --media-root media/\n", + " brainbrew media images-to-refs --manifest brainbrew.yaml --all-targets\n", + " brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/\n", + " brainbrew translate --manifest brainbrew.yaml --target de-standard\n", + " brainbrew workbench serve --manifest brainbrew.yaml\n", + " brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/\n\n", + "Run `brainbrew --help` for command-specific examples.\n", + ), + env!("CARGO_PKG_VERSION") + ) +} + +pub(crate) fn command(name: &str) -> Option<&'static str> { + match name { + "fmt" => Some( + "Usage:\n brainbrew fmt \n\nExamples:\n brainbrew fmt deck.yaml\n brainbrew fmt overlays/languages/da.yaml\n brainbrew fmt brainbrew.yaml\n brainbrew fmt brainbrew.lock\n", + ), + "validate" => Some( + "Usage:\n brainbrew validate [--overlay overlay.yaml ...] [--json]\n brainbrew validate --manifest brainbrew.yaml --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--json]\n\nExamples:\n brainbrew validate deck.yaml\n brainbrew validate deck.yaml --overlay overlays/languages/da.yaml\n brainbrew validate --manifest brainbrew.yaml --target da-standard\n brainbrew validate --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew validate --manifest brainbrew.yaml --target de-extended --json\n", + ), + "compose" => Some( + "Usage:\n brainbrew compose [--overlay overlay.yaml ...] [--out resolved.yaml] [--force]\n brainbrew compose [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--out resolved.yaml] [--force]\n\nExamples:\n brainbrew compose deck.yaml --overlay overlays/languages/da.yaml --out build/da.yaml\n brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml\n brainbrew compose --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew compose --manifest brainbrew.yaml --target da-standard\n\nCompose creates missing in-root output parents. Existing outputs are refused unless --force is supplied; forced replacement is fingerprint-checked, backed up, and recoverable.\n", + ), + "export" => Some( + "Usage:\n brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder [--media-root media/ | --media-mode reference-only] [--force] [--json]\n brainbrew export crowdanki [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--out build/deck-folder] [--media-root media/ | --media-root = ... | --media-mode reference-only] [--force] [--json]\n\nExamples:\n brainbrew export crowdanki deck.yaml --overlay overlays/languages/da.yaml --out build/da-crowdanki\n brainbrew export crowdanki --manifest brainbrew.yaml --target da-standard --media-root media/\n brainbrew export crowdanki --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america --out build/en-america\n brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/\n\nWhen --out is omitted for a manifest target, the output defaults to build/crowdanki/ unless exports.crowdanki.out is configured. Media targets are release-strict by default: every owner root, canonical hash, and byte must validate. --media-mode reference-only explicitly skips roots/bytes for development while retaining semantic checks and reporting NOT RELEASE-READY; a missing root never selects it. Existing output directories are refused unless --force is supplied. The complete clean tree is staged privately and then published with backup/recovery; stale files are never retained.\n", + ), + "import" => Some( + "Usage:\n brainbrew import crowdanki plan --out import-plan.json [--media-root media/ | --media-mode reference-only] [--force] [--json]\n brainbrew import crowdanki review --plan import-plan.json [--json]\n brainbrew import crowdanki apply --plan import-plan.json --approve-plan --out workspace-dir [--media-root media/ | --media-mode reference-only] [--force] [--json]\n\nExamples:\n brainbrew import crowdanki plan build/de-extended --media-root build/de-extended/media --out import-plan.json\n brainbrew import crowdanki review --plan import-plan.json\n brainbrew import crowdanki apply build/de-extended --plan import-plan.json --approve-plan --media-root build/de-extended/media --out imported-workspace\n\nPlan generation writes no Canonical Deck source. Apply creates a complete new `deck.yaml` plus `media/` bootstrap workspace; it never merges into a federated source or overlay stack, and it has no base-versus-overlay ownership inference. Preserve existing source separately. In strict mode it inventories every declared CrowdAnki media path, authorizes it below the selected media root, reads each byte once, and records its SHA-256/length evidence in the review plan. Apply verifies exact source and media bytes before staging the clean recoverable output tree. The destination must not exist unless `--force` cleanly replaces that bootstrap output; symlinks and special targets are refused. `--media-mode reference-only` is explicit non-release behavior and records no byte claim. Automatic suggestions require the explicit reviewed acknowledgement `--approve-plan`; unresolved collisions require edited `override` decisions. The legacy `--accept-suggested-ids` flag is removed.\n", + ), + "lock" => Some( + "Usage:\n brainbrew lock update --package (--path | --git [--ref ] [--rev ] | --tarball ) [--package-manifest brainbrew.yaml] [--lock brainbrew.lock]\n brainbrew lock verify [--lock brainbrew.lock]\n\nExperimental: lock/package federation works, but brainbrew.lock and the brainbrew lock CLI surface may change incompatibly.\n\nExamples:\n brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography\n brainbrew lock update --package anki-geo.ultimate-geography --git https://github.com/anki-geo/ultimate-geography.git --ref main\n brainbrew lock verify\n", + ), + "media" => Some( + "Usage:\n brainbrew media hash [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/] --media-root media/ [--media-root = ...]\n brainbrew media images-to-refs [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/]\n\nExamples:\n brainbrew media hash --manifest brainbrew.yaml --all-targets --media-root media/\n brainbrew media hash --manifest brainbrew.yaml --target en-standard --media-root media/\n brainbrew media images-to-refs --manifest brainbrew.yaml --all-targets\n\n`media hash` computes SHA-256 hashes for declared media files and writes missing or stale values back to deck/overlay source YAML using the include-preserving canonical formatter.\n\n`media images-to-refs` converts whole-field strict `` source values to `!image ` when PATH maps to exactly one declared media ID. Non-strict fields, undeclared paths, and duplicate path declarations stay raw and are counted in the report.\n", + ), + "targets" => Some( + "Usage:\n brainbrew targets [--manifest brainbrew.yaml] [--include package/brainbrew.yaml ...] [--package-root packages/] [--package-ignore ...] [--discovery-max-depth ] [--discovery-max-entries ] [--discovery-max-manifests ] [--json]\n\nExamples:\n brainbrew targets --manifest brainbrew.yaml\n brainbrew targets --manifest brainbrew.yaml --json\n brainbrew targets --package-root ../packages\n brainbrew targets --package-root ../packages --package-ignore 'vendor/**/generated' --discovery-max-entries 200000\n\nAll commands accepting --package-root accept the same discovery flags. Defaults: depth=32, entries=100000, manifests=1000. Discovery is deterministic, never follows symlinks, prunes conventional VCS/build/output/cache trees by exact name, and reports actionable budget errors.\n", + ), + "translations" | "translate" | "translation" => Some( + "Usage:\n brainbrew translate [--manifest brainbrew.yaml] [--target | --all-targets] [--language ] [--overlay ] [--note ] [--field ] [--source ] [--duplicates] [--status ] [--path-prefix ] [--apply | --summary | --context] [--full] [--interactive | --no-interactive] [--json]\n brainbrew translate [--manifest brainbrew.yaml] [--target | --all-targets] [--language ] [--overlay ] [--note ] [--field ] [--status stale] [--path-prefix ] --resolve (confirm | replace) [--old-source ] [--new-source ] [--stale-context ] [--translation ] [--json]\n\nInteractive examples:\n brainbrew translate\n brainbrew translate --manifest fixtures/ultimate-geography/brainbrew.yaml\n brainbrew translate --interactive\n\nScriptable examples:\n brainbrew translations --manifest brainbrew.yaml --target da-standard\n brainbrew translations --manifest brainbrew.yaml --all-targets --language de\n brainbrew translations --manifest brainbrew.yaml --target de-standard --note note.finland --field field.country\n brainbrew translations --manifest brainbrew.yaml --target de-standard --context --source Georgia\n brainbrew translations --manifest brainbrew.yaml --target da-standard --apply\n brainbrew translations --manifest brainbrew.yaml --target da-standard --resolve confirm --old-source 'Old text' --new-source 'New text'\n brainbrew translations --manifest brainbrew.yaml --target da-standard --resolve replace --old-source 'Old text' --new-source 'New text' --translation 'New translation'\n brainbrew translations --manifest brainbrew.yaml --all-targets --summary --json\n brainbrew translations --manifest brainbrew.yaml --target da-standard --json\n\nReport mode is the default and never edits files. In an interactive terminal, missing choices open arrow-key selectors for target, scope, and mode; language and overlay selectors appear only when they disambiguate the selected targets. The human report is translator-focused by default: structural/media/tag fallbacks are hidden and summarized. Use --full to include every scalar fallback. --context shows source and target strings in note/field/card context; combine it with --note, --field, --source, --duplicates, --status, and --language to navigate the translator context view. --summary exports compact per-language/per-overlay counts and de-duplicates identical reports across target variants; human summary output uses narrow aligned columns by default, and --summary --full adds overlay/file columns. --apply inserts source->source translation stubs for missing text fallbacks in non-interactive mode, while interactive apply lets you select rows with Space/Enter and choose one action for all selected rows (mark no-change, direct/contextual stub, ignore-path, skip) or decide per row. --resolve confirm promotes matching stale translations under the current source text; --resolve replace first uses the same stale-record resolution path, then installs the provided --translation. Omit --old-source/--new-source within a narrow target/overlay/scope to batch-resolve all matching current-base stale records. Use --path-prefix with paths from a diff to scope a changed-base review.\n", + ), + "workbench" => Some( + "Usage:\n brainbrew workbench serve [--manifest brainbrew.yaml] [--port ] [--no-open] [--dev-assets ] [--media-root | --media-root = ...]\n\nExamples:\n brainbrew workbench serve --manifest brainbrew.yaml\n brainbrew workbench serve --manifest brainbrew.yaml --port 0 --no-open\n brainbrew workbench serve --manifest brainbrew.yaml --dev-assets target/workbench-ui\n brainbrew workbench serve --manifest brainbrew.yaml --media-root media/\n\nStarts a read-only localhost Deck Workbench server bound to 127.0.0.1. Browsing, comparison, preview, and browser-local draft staging remain available, but normal and distributed binaries reject source mutation. Port 0 selects an available port. Release builds serve embedded UI assets from the brainbrew binary; --dev-assets serves a development asset directory such as the output of `devenv shell workbench-ui-watch`. Use --media-root to serve declared media assets from a directory outside the manifest root.\n\nUnsafe write-path development requires both a binary explicitly compiled with Cargo feature `workbench-write-dev` and the runtime flag `--enable-write`. The flag is rejected by normal binaries. This mode is unsupported for release use and is visibly/API marked as development-only.\n", + ), + "verify" => Some( + "Usage:\n brainbrew verify [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/] [--media-root media/ | --media-root = ... | --media-mode reference-only] [--translation-coverage lenient|strict] [--skip-content-validation] [--json]\n\nExamples:\n brainbrew verify --manifest brainbrew.yaml --all-targets\n brainbrew verify --manifest brainbrew.yaml --target da-standard\n brainbrew verify --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/\n brainbrew verify --manifest brainbrew.yaml --target de-release --translation-coverage strict\n brainbrew verify --manifest brainbrew.yaml --target legacy-sloppy-html --skip-content-validation\n\nMedia targets are release-strict by default: every final owner root, canonical non-empty SHA-256, and byte must validate. --media-mode reference-only is explicit non-release development mode and still validates references, collisions, safe paths, and present hash syntax. It reports NOT RELEASE-READY in human/JSON output and cannot be combined with --media-root. Targets without media are unaffected. Verify also checks rendered deck descriptions/card templates as lightweight HTML and note-type styling as balanced CSS. Use --skip-content-validation only for legacy false positives.\n", + ), + "explain" => Some( + "Usage:\n brainbrew explain [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--json]\n\nExamples:\n brainbrew explain --manifest brainbrew.yaml --target da-standard\n brainbrew explain --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew explain --manifest brainbrew.yaml --target de-extended --json\n", + ), + "diff" => Some( + "Usage:\n brainbrew diff [--json] [--exit-code]\n brainbrew diff --as-overlay --id [--kind patch]\n\nExamples:\n brainbrew diff deck.yaml edited.yaml\n brainbrew diff deck.yaml edited.yaml --json --exit-code\n brainbrew diff deck.yaml edited.yaml --as-overlay --id overlay.patch.capitals --kind patch\n\nWith --exit-code, no differences exit 0, semantic differences exit 2 after printing the normal report, and operational errors exit 1.\n", + ), + _ => None, + } +} + +pub(crate) fn usage_error(command: &str, fallback: &str) -> String { + self::command(command) + .map(str::to_owned) + .unwrap_or_else(|| fallback.to_owned()) +} diff --git a/crates/brain-brew-cli/src/io.rs b/crates/brain-brew-cli/src/io.rs new file mode 100644 index 0000000..69849d0 --- /dev/null +++ b/crates/brain-brew-cli/src/io.rs @@ -0,0 +1,362 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_core::{CanonicalDeck, Overlay}; +use brain_brew_formats::canonical_source_document::CanonicalSourceDocument; +use brain_brew_formats::overlay_source_document::OverlaySourceDocument; +use brain_brew_formats::source_document::{IncludeRequest, SourceFile, SourceProvenance}; +use brain_brew_formats::{canonical_yaml, lockfile, manifest, media_map, source_includes}; +use serde_yaml::Value; + +use crate::path_authorization::PathAuthorizer; + +pub(crate) fn format_source(input: &str) -> Result { + let mut errors = Vec::new(); + match source_includes::format_preserving_file_includes(input, canonical_yaml::format_str) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("deck: {error}")), + } + match source_includes::format_preserving_file_includes( + input, + canonical_yaml::overlay_format_str, + ) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("overlay: {error}")), + } + match manifest::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("manifest: {error}")), + } + match lockfile::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("lockfile: {error}")), + } + match media_map::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("media map: {error}")), + } + Err(format!( + "unrecognized Brain Brew source file ({})", + errors.join("; ") + )) +} + +pub(crate) fn format_source_at(_path: &Path, input: &str) -> Result { + format_source(input) +} + +pub(crate) fn canonical_source_document( + path: &Path, + input: &str, +) -> Result { + let context = source_context_for_path(path)?; + CanonicalSourceDocument::parse_with_includes(source_file(path, input, &context)?, |request| { + load_source_include(request, &context) + }) + .map_err(|error| error.to_string()) +} + +pub(crate) fn overlay_source_document( + path: &Path, + input: &str, +) -> Result { + let context = source_context_for_path(path)?; + OverlaySourceDocument::parse_with_includes(source_file(path, input, &context)?, |request| { + load_source_include(request, &context) + }) + .map_err(|error| error.to_string()) +} + +fn source_file(path: &Path, input: &str, context: &SourceContext) -> Result { + let absolute = + fs::canonicalize(path).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(SourceFile::new( + SourceProvenance::new(absolute.display().to_string()) + .with_source_root(context.root.display().to_string()), + input, + )) +} + +fn load_source_include( + request: &IncludeRequest, + context: &SourceContext, +) -> Result { + let declaring = Path::new(request.referring_source().source_name()); + let absolute = + authorize_include_read(declaring, request.schema_path(), request.target(), context)?; + let text = fs::read_to_string(&absolute) + .map_err(|error| format!("{}: {error}", absolute.display()))?; + Ok(SourceFile::new( + SourceProvenance::new(absolute.display().to_string()) + .with_source_root(context.root.display().to_string()), + text, + )) +} + +pub(crate) fn read_deck(path: &Path) -> Result { + let context = source_context_for_path(path)?; + read_deck_with_context(path, &context) +} + +pub(crate) fn canonical_document_from_package( + path: &Path, + package_root: &Path, + include_roots: &[PathBuf], +) -> Result { + let context = SourceContext { + root: package_root.to_path_buf(), + include_roots: include_roots.to_vec(), + }; + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + CanonicalSourceDocument::parse_with_includes(source_file(path, &input, &context)?, |request| { + load_source_include(request, &context) + }) + .map_err(|error| error.to_string()) +} + +fn read_deck_with_context(path: &Path, context: &SourceContext) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + CanonicalSourceDocument::parse_with_includes(source_file(path, &input, context)?, |request| { + load_source_include(request, context) + }) + .map(|document| document.resolved_deck().clone()) + .map_err(|error| error.to_string()) +} + +pub(crate) fn overlay_document_from_package( + path: &Path, + package_root: &Path, + include_roots: &[PathBuf], +) -> Result { + let context = SourceContext { + root: package_root.to_path_buf(), + include_roots: include_roots.to_vec(), + }; + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + OverlaySourceDocument::parse_with_includes(source_file(path, &input, &context)?, |request| { + load_source_include(request, &context) + }) + .map_err(|error| error.to_string()) +} + +fn read_overlay_with_context(path: &Path, context: &SourceContext) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + overlay_from_source_text_with_context(path, &input, context) +} + +pub(crate) fn overlay_from_source_text(path: &Path, input: &str) -> Result { + let context = source_context_for_path(path)?; + overlay_from_source_text_with_context(path, input, &context) +} + +fn overlay_from_source_text_with_context( + path: &Path, + input: &str, + context: &SourceContext, +) -> Result { + OverlaySourceDocument::parse_with_includes(source_file(path, input, context)?, |request| { + load_source_include(request, context) + }) + .map(|document| document.resolved_overlay().clone()) + .map_err(|error| error.to_string()) +} + +pub(crate) fn read_manifest(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + manifest::from_str(&input).map_err(|error| format!("{}: {error}", path.display())) +} + +pub(crate) fn read_deck_and_overlays( + deck_path: &Path, + overlay_paths: &[String], +) -> Result<(CanonicalDeck, Vec<(String, Overlay)>), String> { + let context = source_context_for_path(deck_path)?; + let deck = read_deck_with_context(deck_path, &context)?; + let overlays = overlay_paths + .iter() + .map(|path| { + Ok(( + path.clone(), + read_overlay_with_context(Path::new(path), &context)?, + )) + }) + .collect::, String>>()?; + Ok((deck, overlays)) +} + +pub(crate) fn read_and_compose_deck( + deck_path: &Path, + overlay_paths: &[String], +) -> Result { + let (deck, overlays) = read_deck_and_overlays(deck_path, overlay_paths)?; + deck.compose( + &overlays + .into_iter() + .map(|(_, overlay)| overlay) + .collect::>(), + ) + .map_err(|error| error.to_string()) +} + +pub(crate) fn verify_canonical_deck_format(path: &Path) -> Result<(), String> { + verify_format_with(path, canonical_yaml::format_str) +} + +pub(crate) fn verify_overlay_format(path: &Path) -> Result<(), String> { + verify_format_with(path, canonical_yaml::overlay_format_str) +} + +pub(crate) fn verify_manifest_format(path: &Path) -> Result<(), String> { + verify_format_with(path, manifest::format_str) +} + +fn verify_format_with( + path: &Path, + format: impl FnOnce(&str) -> Result, +) -> Result<(), String> +where + E: ToString, +{ + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let formatted = source_includes::format_preserving_file_includes(&input, format)?; + if formatted != input { + return Err(format!("{} is not in canonical format", path.display())); + } + Ok(()) +} + +pub(crate) fn configured_crowdanki_out( + manifest: &manifest::FederatedDeckManifest, + target: &str, +) -> Option { + manifest + .targets + .get(target)? + .exports + .crowdanki + .as_ref()? + .out + .clone() +} + +pub(crate) fn manifest_root(path: &Path) -> PathBuf { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() +} + +pub(crate) struct SourceContext { + pub(crate) root: PathBuf, + pub(crate) include_roots: Vec, +} + +pub(crate) fn workspace_root_for_source_path(path: &Path) -> PathBuf { + nearest_manifest_root(path).unwrap_or_else(|| manifest_root(path)) +} + +pub(crate) fn source_context_for_path(path: &Path) -> Result { + let root = workspace_root_for_source_path(path); + let manifest_path = root.join("brainbrew.yaml"); + let include_roots = if manifest_path.exists() { + let manifest = read_manifest(&manifest_path)?; + include_roots_from_manifest(&manifest_path, &root, &manifest)? + } else { + Vec::new() + }; + Ok(SourceContext { + root, + include_roots, + }) +} + +fn nearest_manifest_root(path: &Path) -> Option { + let mut current = path.parent()?; + loop { + if current.join("brainbrew.yaml").exists() { + return Some(current.to_path_buf()); + } + current = current.parent()?; + } +} + +pub(crate) fn include_roots_from_manifest( + manifest_path: &Path, + root: &Path, + manifest: &manifest::FederatedDeckManifest, +) -> Result, String> { + let authorizer = PathAuthorizer::new("package", root)?; + manifest + .include_roots + .iter() + .enumerate() + .map(|(index, include_root)| { + authorizer + .authorize_read( + manifest_path, + format!("include_roots[{index}]"), + include_root, + ) + .map(|path| path.into_path_buf()) + .map_err(|error| error.to_string()) + }) + .collect() +} + +pub(crate) fn top_level_media_include_path(value: &Value) -> Result, String> { + let Some(media_value) = value + .as_mapping() + .and_then(|mapping| mapping.get(Value::String("media".to_owned()))) + else { + return Ok(None); + }; + let Value::Tagged(tagged) = media_value else { + return Ok(None); + }; + if tagged.tag != "include" { + return Ok(None); + } + let Value::String(path) = &tagged.value else { + return Err("media !include path must be a scalar string".to_owned()); + }; + Ok(Some(path.clone())) +} + +pub(crate) fn resolve_include_target_for_context( + include_path: &str, + context: &SourceContext, +) -> Result { + authorize_include_read( + &context.root.join("brainbrew.yaml"), + "!include", + include_path, + context, + ) +} + +fn authorize_include_read( + declaring_file: &Path, + field: &str, + include_path: &str, + context: &SourceContext, +) -> Result { + let mut roots = vec![("package", context.root.as_path())]; + roots.extend( + context + .include_roots + .iter() + .map(|root| ("configured include", root.as_path())), + ); + let mut not_found = None; + for (name, root) in roots { + let authorizer = PathAuthorizer::new(name, root)?; + match authorizer.authorize_read(declaring_file, field, include_path) { + Ok(path) => return Ok(path.into_path_buf()), + Err(error) if error.reason.contains("cannot be resolved for reading") => { + not_found = Some(error.to_string()); + } + Err(error) => return Err(error.to_string()), + } + } + Err(not_found.unwrap_or_else(|| "no selected include root is available".to_owned())) +} diff --git a/crates/brain-brew-cli/src/main.rs b/crates/brain-brew-cli/src/main.rs new file mode 100644 index 0000000..e430f65 --- /dev/null +++ b/crates/brain-brew-cli/src/main.rs @@ -0,0 +1,168 @@ +//! Command-line entry point for Brain Brew. + +use std::env; +use std::process; + +mod args; +mod commands; +mod fetch_policy; +mod help; +mod io; +mod media_assets; +mod media_ownership; +mod media_verification; +mod output; +mod output_transaction; +mod overlay_draft; +mod package_resolver; +mod package_tree; +mod path_authorization; +mod planner; +mod workspace_mutation; +mod workspace_transaction; + +fn main() { + let args = env::args().skip(1).collect::>(); + let json_error_output = json_error_output_requested(&args); + if let Err(error) = run(&args) { + if error == output::TYPED_ERROR_PENDING && output::print_pending_error(json_error_output) { + process::exit(1); + } + if error == output::DIFFERENCES_FOUND { + process::exit(2); + } + if error != output::JSON_ERROR_ALREADY_PRINTED { + if json_error_output { + output::print_json_error( + args.first().map(String::as_str).unwrap_or("brainbrew"), + &error, + ); + } else { + output::print_error(&error); + } + } + process::exit(1); + } +} + +fn run(args: &[String]) -> Result<(), String> { + let Some(command) = args.first().map(String::as_str) else { + print_usage(); + return Ok(()); + }; + + let command = canonical_command(command); + + if let Some(help_command) = valid_help_request(args) { + if let Some(help_command) = help_command { + print!( + "{}", + help::command(help_command).expect("recognized command has help") + ); + } else { + print_usage(); + } + return Ok(()); + } + if matches!(command, "--help" | "-h") { + return Err("--help does not accept unexpected trailing arguments".to_owned()); + } + if matches!(command, "--version" | "-V") && args.len() != 1 { + return Err("--version does not accept trailing arguments".to_owned()); + } + + match command { + "fmt" => commands::fmt::run(&args[1..]), + "validate" => commands::validate::run(&args[1..]), + "compose" => commands::compose::run(&args[1..]), + "export" => commands::export::run(&args[1..]), + "import" => commands::import::run(&args[1..]), + "lock" => commands::lock::run(&args[1..]), + "media" => commands::media::run(&args[1..]), + "targets" => commands::targets::run(&args[1..]), + "translations" => commands::translations::run(&args[1..]), + "verify" => commands::verify::run(&args[1..]), + "workbench" => commands::workbench::run(&args[1..]), + "explain" => commands::explain::run(&args[1..]), + "diff" => commands::diff::run(&args[1..]), + "--help" | "-h" => { + print_usage(); + Ok(()) + } + "--version" | "-V" => { + println!("brainbrew {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + other => Err(unknown_command_error(other)), + } +} + +fn canonical_command(command: &str) -> &str { + match command { + "translate" | "translation" => "translations", + other => other, + } +} + +fn json_error_output_requested(args: &[String]) -> bool { + args.iter().any(|arg| arg == "--json") +} + +fn valid_help_request(args: &[String]) -> Option> { + let help_count = args + .iter() + .filter(|arg| matches!(arg.as_str(), "--help" | "-h")) + .count(); + if help_count != 1 { + return None; + } + let words = args + .iter() + .filter(|arg| !matches!(arg.as_str(), "--help" | "-h")) + .map(String::as_str) + .collect::>(); + if words.is_empty() { + return Some(None); + } + let command = canonical_command(words[0]); + let valid = match command { + "lock" => matches!(words.as_slice(), ["lock"] | ["lock", "update" | "verify"]), + "media" => matches!( + words.as_slice(), + ["media"] | ["media", "hash" | "images-to-refs"] + ), + "workbench" => matches!(words.as_slice(), ["workbench"] | ["workbench", "serve"]), + "import" => matches!(words.as_slice(), ["import"] | ["import", "crowdanki"]), + "export" => matches!(words.as_slice(), ["export"] | ["export", "crowdanki"]), + other => help::command(other).is_some() && words.len() == 1, + }; + valid.then_some(Some(command)) +} + +fn unknown_command_error(command: &str) -> String { + if command.starts_with("translat") || levenshtein_one_or_two(command, "translations") { + format!("unknown command {command:?}\n\nDid you mean:\n brainbrew translations") + } else { + format!("unknown command {command:?}") + } +} + +fn levenshtein_one_or_two(left: &str, right: &str) -> bool { + let mut previous = (0..=right.len()).collect::>(); + let mut current = vec![0; right.len() + 1]; + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right.chars().enumerate() { + let substitution = usize::from(left_char != right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution); + } + std::mem::swap(&mut previous, &mut current); + } + previous[right.len()] <= 2 +} + +fn print_usage() { + print!("{}", help::general()); +} diff --git a/crates/brain-brew-cli/src/media_assets.rs b/crates/brain-brew-cli/src/media_assets.rs new file mode 100644 index 0000000..438c648 --- /dev/null +++ b/crates/brain-brew-cli/src/media_assets.rs @@ -0,0 +1,271 @@ +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use brain_brew_core::CanonicalDeck; +use brain_brew_formats::media; + +use crate::media_ownership::MediaRootSelections; +use crate::media_verification::MediaVerificationMode; +use crate::path_authorization::PathAuthorizer; +use crate::planner::{MediaDeclarationProvenance, TargetPlan}; + +#[derive(Debug)] +pub(crate) struct MediaVerificationResult { + pub(crate) warnings: Vec, + pub(crate) assets: Vec<(PathBuf, Vec)>, +} + +pub(crate) fn validate_media_semantics( + deck: &CanonicalDeck, + mode: MediaVerificationMode, +) -> Result, String> { + let report = media::reference_report(deck); + if !report.errors.is_empty() { + return Err(media::MediaValidationReport { + errors: report.errors, + } + .to_string()); + } + let hash_policy = match mode { + MediaVerificationMode::Strict => media::MediaHashPolicy::Required, + MediaVerificationMode::ReferenceOnly => media::MediaHashPolicy::Optional, + }; + media::validate_declarations(deck, hash_policy).map_err(|error| error.to_string())?; + Ok(report + .warnings + .into_iter() + .map(|warning| warning.message) + .collect()) +} + +pub(crate) fn collect_media_assets( + deck: &CanonicalDeck, + media_root: &Path, +) -> Result)>, String> { + validate_media_semantics(deck, MediaVerificationMode::Strict)?; + let assets = read_media_assets(deck, media_root)?; + media::validate_hashes(deck, &assets).map_err(|error| error.to_string())?; + let mut output = BTreeMap::new(); + for declaration in deck.media.values() { + let bytes = assets.get(&declaration.path).cloned().ok_or_else(|| { + format!( + "{}: declared media file {:?} was not read", + media_root.display(), + declaration.path + ) + })?; + output.insert(PathBuf::from("media").join(&declaration.path), bytes); + } + Ok(output.into_iter().collect()) +} + +/// Validate a composed target by resolving every declaration through its final +/// package owner. A relative path is never allowed to select another package's root. +pub(crate) fn verify_owned_media( + plan: &TargetPlan, + deck: &CanonicalDeck, + roots: &MediaRootSelections, + mode: MediaVerificationMode, + collect_assets: bool, +) -> Result { + let mut warnings = validate_media_semantics(deck, mode)?; + ensure_plan_matches_deck(plan, deck)?; + plan.media_reference_bindings(deck)?; + let assets = match mode { + MediaVerificationMode::Strict => { + collect_owned_media_assets(plan, deck, roots, collect_assets)? + } + MediaVerificationMode::ReferenceOnly => Vec::new(), + }; + if let Some(warning) = mode.development_warning(deck.media.len()) { + warnings.push(warning); + } + Ok(MediaVerificationResult { + warnings, + assets: if collect_assets { assets } else { Vec::new() }, + }) +} + +fn collect_owned_media_assets( + plan: &TargetPlan, + deck: &CanonicalDeck, + roots: &MediaRootSelections, + retain_assets: bool, +) -> Result)>, String> { + validate_media_semantics(deck, MediaVerificationMode::Strict)?; + roots.require_for_plan(plan)?; + ensure_plan_matches_deck(plan, deck)?; + let mut assets = BTreeMap::new(); + for declaration in plan.media_declarations.values() { + let root = roots.require_for_declaration(&plan.qualified_name, declaration)?; + let (bytes, actual) = + read_owned_asset(&plan.qualified_name, declaration, root, retain_assets)?; + if declaration.sha256.is_empty() { + return Err(owned_error( + &plan.qualified_name, + declaration, + root, + "media entry has empty sha256", + )); + } + if actual != declaration.sha256 { + return Err(owned_error( + &plan.qualified_name, + declaration, + root, + &format!( + "sha256 mismatch: expected {}, actual {actual}", + declaration.sha256 + ), + )); + } + if let Some(bytes) = bytes { + let output_path = PathBuf::from("media").join(&declaration.path); + if let Some(previous) = assets.insert(output_path.clone(), bytes.clone()) + && previous != bytes + { + return Err(format!( + "media output collision for target {} at {} selected different bytes", + plan.qualified_name, + output_path.display() + )); + } + } + } + Ok(assets.into_iter().collect()) +} + +fn read_media_assets( + deck: &CanonicalDeck, + media_root: &Path, +) -> Result>, String> { + let mut assets = BTreeMap::new(); + let authorizer = PathAuthorizer::new("media", media_root)?; + for media in deck.media.values() { + let full_path = authorizer + .authorize_read( + Path::new(""), + format!("media.{}.path", media.id), + &media.path, + ) + .map_err(|error| error.to_string())? + .into_path_buf(); + match fs::read(&full_path) { + Ok(bytes) => { + assets.insert(media.path.clone(), bytes); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("{}: {error}", full_path.display())), + } + } + Ok(assets) +} + +fn ensure_plan_matches_deck(plan: &TargetPlan, deck: &CanonicalDeck) -> Result<(), String> { + if plan.media_declarations.len() != deck.media.len() { + return Err(format!( + "media ownership plan for target {} has {} declarations, composed deck has {}", + plan.qualified_name, + plan.media_declarations.len(), + deck.media.len() + )); + } + for binding in plan.media_reference_bindings(deck)? { + let Some(owner) = plan.media_declarations.get(&binding.declaration.id) else { + return Err(format!( + "media ownership plan for target {} lost declaration {} bound from reference {}", + plan.qualified_name, binding.declaration.id, binding.reference + )); + }; + if owner != &binding.declaration { + return Err(format!( + "media ownership plan for target {} changed owner after binding reference {}", + plan.qualified_name, binding.reference + )); + } + } + for media in deck.media.values() { + let Some(owner) = plan.media_declarations.get(media.id.as_str()) else { + return Err(format!( + "media ownership plan for target {} has no owner for declaration {}", + plan.qualified_name, media.id + )); + }; + if owner.path != media.path || owner.sha256 != media.sha256 { + return Err(format!( + "media ownership plan for target {} disagrees with final declaration {}: planned path/hash {:?}/{:?}, composed {:?}/{:?}", + plan.qualified_name, media.id, owner.path, owner.sha256, media.path, media.sha256 + )); + } + } + Ok(()) +} + +fn read_owned_asset( + target: &str, + declaration: &MediaDeclarationProvenance, + root: &Path, + retain_bytes: bool, +) -> Result<(Option>, String), String> { + let path = owned_asset_path(target, declaration, root)?; + let mut file = File::open(&path) + .map_err(|error| owned_error(target, declaration, root, &error.to_string()))?; + let mut bytes = Vec::new(); + let mut hasher = Sha256::new(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut chunk) + .map_err(|error| owned_error(target, declaration, root, &error.to_string()))?; + if count == 0 { + break; + } + hasher.update(&chunk[..count]); + if retain_bytes { + bytes.extend_from_slice(&chunk[..count]); + } + } + Ok(( + retain_bytes.then_some(bytes), + format!("{:x}", hasher.finalize()), + )) +} + +fn owned_asset_path( + target: &str, + declaration: &MediaDeclarationProvenance, + root: &Path, +) -> Result { + PathAuthorizer::new( + format!("media for package {}", declaration.package_label()), + root, + )? + .authorize_read( + &declaration.source, + format!("media.{}.path", declaration.id), + &declaration.path, + ) + .map(|path| path.into_path_buf()) + .map_err(|error| owned_error(target, declaration, root, &error.to_string())) +} + +fn owned_error( + target: &str, + declaration: &MediaDeclarationProvenance, + root: &Path, + reason: &str, +) -> String { + format!( + "media ownership error for target {target}, package {}, declaration {}, source {}, path {:?}, root {} ({}): {reason}", + declaration.package_label(), + declaration.id, + declaration.source.display(), + declaration.path, + root.display(), + declaration.source_kind.ownership_name() + ) +} diff --git a/crates/brain-brew-cli/src/media_ownership.rs b/crates/brain-brew-cli/src/media_ownership.rs new file mode 100644 index 0000000..c28ce85 --- /dev/null +++ b/crates/brain-brew-cli/src/media_ownership.rs @@ -0,0 +1,179 @@ +//! Package-qualified media-root selection and final declaration ownership. +//! +//! A media path never chooses its filesystem root. The registry/planner chooses +//! the declaration owner first; this module then binds that owner to exactly one +//! caller-authorized root. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::planner::{ManifestRegistry, MediaDeclarationProvenance, TargetPlan}; + +#[derive(Clone, Debug, Default)] +pub(crate) struct MediaRootSelections { + roots: BTreeMap, + supplied: bool, +} + +impl MediaRootSelections { + pub(crate) fn parse( + registry: &ManifestRegistry, + raw_values: &[String], + caller_root: &Path, + ) -> Result { + let known = registry + .manifests() + .iter() + .filter_map(|loaded| loaded.identity.as_ref().map(|identity| identity.id.clone())) + .collect::>(); + let root_key = registry + .root() + .identity + .as_ref() + .map(|identity| identity.id.clone()) + .unwrap_or_else(|| root_workspace_key(®istry.root().root)); + let mut roots = BTreeMap::new(); + for raw in raw_values { + let (package, path) = match raw.split_once('=') { + Some((package, path)) => { + if package.is_empty() || path.is_empty() { + return Err(format!( + "invalid package-qualified media root {raw:?}; expected =" + )); + } + if !known.contains(package) { + return Err(format!( + "unknown package {package:?} in --media-root {raw:?}; known packages: {}", + known.iter().cloned().collect::>().join(", ") + )); + } + (package.to_owned(), PathBuf::from(path)) + } + None => (root_key.clone(), PathBuf::from(raw)), + }; + if roots.contains_key(&package) { + return Err(format!( + "duplicate media root for package {package:?}; select exactly one root" + )); + } + let requested = if path.is_absolute() { + path + } else { + caller_root.join(path) + }; + let canonical = fs::canonicalize(&requested).map_err(|error| { + format!( + "selected media root for package {package:?} {}: {error}", + requested.display() + ) + })?; + if !canonical.is_dir() { + return Err(format!( + "selected media root for package {package:?} {} is not a directory", + canonical.display() + )); + } + roots.insert(package, canonical); + } + Ok(Self { + roots, + supplied: !raw_values.is_empty(), + }) + } + + pub(crate) fn supplied(&self) -> bool { + self.supplied + } + + pub(crate) fn require_for_plan(&self, plan: &TargetPlan) -> Result<(), String> { + if !self.supplied { + return Ok(()); + } + for declaration in plan.media_declarations.values() { + self.require_for_declaration(&plan.qualified_name, declaration)?; + } + Ok(()) + } + + pub(crate) fn require_for_declaration( + &self, + target: &str, + declaration: &MediaDeclarationProvenance, + ) -> Result<&Path, String> { + let key = declaration_key(declaration); + self.roots.get(&key).map(PathBuf::as_path).ok_or_else(|| { + format!( + "missing media root for target {target}, package {}, declaration {}, path {:?}; pass --media-root {}=", + declaration.package_label(), + declaration.id, + declaration.path, + declaration.package_label() + ) + }) + } + + pub(crate) fn explicit_for_declaration( + &self, + declaration: &MediaDeclarationProvenance, + ) -> Option<&Path> { + self.roots + .get(&declaration_key(declaration)) + .map(PathBuf::as_path) + } +} + +pub(crate) fn declaration_key(declaration: &MediaDeclarationProvenance) -> String { + declaration + .package + .as_ref() + .map(|package| package.id.clone()) + .unwrap_or_else(|| root_workspace_key(&declaration.package_root)) +} + +fn root_workspace_key(root: &Path) -> String { + format!("", root.display()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn unqualified_root_is_root_package_only_and_duplicates_and_unknowns_fail() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("package"); + let media = temp.path().join("media"); + fs::create_dir_all(&package).unwrap(); + fs::create_dir_all(&media).unwrap(); + fs::write(package.join("deck.yaml"), "deck:\n id: deck.x\n name: X\n description: X\nnote_types: {}\nnotes: {}\nmedia: {}\ntombstones: []\n").unwrap(); + fs::write(package.join("brainbrew.yaml"), "package:\n id: example.root\n version: 1.0.0\nbase: deck.yaml\noverlays: {}\ntargets:\n base:\n overlays: []\n").unwrap(); + let registry = ManifestRegistry::load(&package.join("brainbrew.yaml"), &[], &[]).unwrap(); + + let selected = + MediaRootSelections::parse(®istry, &[media.display().to_string()], temp.path()) + .unwrap(); + assert!(selected.roots.contains_key("example.root")); + + let duplicate = MediaRootSelections::parse( + ®istry, + &[ + media.display().to_string(), + format!("example.root={}", media.display()), + ], + temp.path(), + ) + .unwrap_err(); + assert!(duplicate.contains("duplicate media root"), "{duplicate}"); + + let unknown = MediaRootSelections::parse( + ®istry, + &[format!("example.unknown={}", media.display())], + temp.path(), + ) + .unwrap_err(); + assert!(unknown.contains("unknown package"), "{unknown}"); + } +} diff --git a/crates/brain-brew-cli/src/media_verification.rs b/crates/brain-brew-cli/src/media_verification.rs new file mode 100644 index 0000000..e35d1a3 --- /dev/null +++ b/crates/brain-brew-cli/src/media_verification.rs @@ -0,0 +1,38 @@ +//! Release media verification policy shared by verify and export. + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum MediaVerificationMode { + #[default] + Strict, + ReferenceOnly, +} + +impl MediaVerificationMode { + pub(crate) fn parse(value: &str) -> Result { + match value { + "strict" => Ok(Self::Strict), + "reference-only" => Ok(Self::ReferenceOnly), + other => Err(format!( + "invalid media verification mode {other:?}; expected strict or reference-only" + )), + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Strict => "strict", + Self::ReferenceOnly => "reference_only", + } + } + + pub(crate) fn release_ready(self, declaration_count: usize) -> bool { + declaration_count == 0 || self == Self::Strict + } + + pub(crate) fn development_warning(self, declaration_count: usize) -> Option { + (self == Self::ReferenceOnly && declaration_count > 0).then(|| { + "MEDIA REFERENCE-ONLY DEVELOPMENT MODE: declaration/reference consistency was checked, but media roots and asset bytes were not validated; this result is NOT RELEASE-READY" + .to_owned() + }) + } +} diff --git a/crates/brain-brew-cli/src/output.rs b/crates/brain-brew-cli/src/output.rs new file mode 100644 index 0000000..de03f9d --- /dev/null +++ b/crates/brain-brew-cli/src/output.rs @@ -0,0 +1,497 @@ +use std::cell::RefCell; +use std::env; +use std::io::{self, IsTerminal}; + +use brain_brew_core::{ + CanonicalDeck, ComposePrecondition, DomainDiagnostic, FieldValue, SemanticChangeKind, + SemanticDiff, TombstoneAddress, +}; +use brain_brew_formats::manifest; +use serde_json::{Value, json}; + +pub(crate) const JSON_ERROR_ALREADY_PRINTED: &str = "__brainbrew_json_error_already_printed"; +pub(crate) const DIFFERENCES_FOUND: &str = "__brainbrew_differences_found"; +pub(crate) const DIAGNOSTIC_SCHEMA_VERSION: u32 = 1; +pub(crate) const TYPED_ERROR_PENDING: &str = "__brainbrew_typed_error_pending"; + +#[derive(Clone, Debug)] +pub(crate) struct PendingDiagnosticError { + command: String, + context: Value, + message: String, + diagnostics: Vec, +} + +thread_local! { + static PENDING_DIAGNOSTIC_ERROR: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn domain_error( + command: &str, + context: Value, + message: impl Into, + diagnostics: Vec, +) -> String { + PENDING_DIAGNOSTIC_ERROR.with(|pending| { + *pending.borrow_mut() = Some(PendingDiagnosticError { + command: command.to_owned(), + context, + message: message.into(), + diagnostics, + }); + }); + TYPED_ERROR_PENDING.to_owned() +} + +pub(crate) fn compose_error( + command: &str, + context: Value, + report: &brain_brew_core::ComposeReport, +) -> String { + domain_error( + command, + context, + "composition failed", + report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect(), + ) +} + +pub(crate) fn validation_error( + command: &str, + context: Value, + report: &brain_brew_core::ValidationReport, +) -> String { + domain_error( + command, + context, + "validation failed", + report + .errors + .iter() + .map(|error| error.diagnostic()) + .collect(), + ) +} + +pub(crate) fn print_pending_error(json_output: bool) -> bool { + let pending = PENDING_DIAGNOSTIC_ERROR.with(|pending| pending.borrow_mut().take()); + let Some(pending) = pending else { + return false; + }; + if json_output { + print_json_diagnostic_error( + &pending.command, + pending.context, + &pending.message, + &pending.diagnostics, + ); + } else { + eprintln!("{}", render_diagnostics(&pending.diagnostics)); + } + true +} + +#[derive(Clone, Debug)] +pub(crate) struct CliError { + pub(crate) version: u32, + pub(crate) code: &'static str, + pub(crate) category: &'static str, + pub(crate) message: String, + pub(crate) source: Option, + pub(crate) path: Option, + pub(crate) details: serde_json::Map, +} + +impl CliError { + pub(crate) fn from_message(message: impl Into) -> Self { + let message = message.into(); + let (code, category) = ("adapter_error", "adapter"); + let (source, path) = (None, None); + Self { + version: 1, + code, + category, + message, + source, + path, + details: serde_json::Map::new(), + } + } + + pub(crate) fn human_message(&self) -> &str { + &self.message + } + + fn json(&self) -> Value { + let mut value = json!({ + "schema_version": self.version, + "version": self.version, + "code": self.code, + "category": self.category, + "message": self.message, + "source": self.source, + "path": self.path, + "details": self.details, + }); + // Preserve the pre-v1 route fields during the compatibility window while + // making the versioned fields above authoritative. + if let Some(object) = value.as_object_mut() { + for (key, detail) in &self.details { + object.entry(key.clone()).or_insert_with(|| detail.clone()); + } + } + value + } +} + +pub(crate) fn diagnostic_json(diagnostic: &DomainDiagnostic) -> Value { + let graph = diagnostic.field_graph_error.as_ref().map(|error| { + json!({ + "kind": format!("{:?}", error.kind), + "note_id": error.note_id.as_str(), + "field_id": error.field_id.as_str(), + "consuming_path": error.consuming_path, + "dependency": error.dependency, + "representation": error.representation.map(|kind| kind.as_str()), + "cycle": error.cycle, + }) + }); + json!({ + "code": diagnostic.code, + "category": diagnostic.category.as_str(), + "path": diagnostic.path.as_ref().map(ToString::to_string).unwrap_or_else(|| diagnostic.address.clone()), + "address": diagnostic.address, + "overlay": diagnostic.overlay_id.as_ref().map(|id| id.as_str()), + "source": diagnostic.source_id.as_ref().map(|id| id.as_str()), + "intent": diagnostic.intent.map(|intent| intent.as_str()), + "entity_kind": diagnostic.entity_kind.map(|kind| kind.as_str()), + "expected": diagnostic.expected.as_ref().map(precondition_json), + "actual": diagnostic.actual.as_ref().map(precondition_json), + "conflict": (diagnostic.first_conflict_participant.is_some() + || diagnostic.current_conflict_participant.is_some()).then(|| json!({ + "first": diagnostic.first_conflict_participant.as_ref().map(|id| id.as_str()), + "current": diagnostic.current_conflict_participant.as_ref().map(|id| id.as_str()), + })), + "original_removal": diagnostic.original_removal.as_ref().map(|record| record.address.to_string()), + "field_graph": graph, + "details": {}, + "children": diagnostic.children.iter().map(diagnostic_json).collect::>(), + "message": diagnostic.message, + }) +} + +fn precondition_json(value: &ComposePrecondition) -> Value { + match value { + ComposePrecondition::Fingerprint(value) => { + json!({"kind": "fingerprint", "value": value.to_string()}) + } + ComposePrecondition::Value(value) => json!({"kind": "value", "value": value}), + ComposePrecondition::FieldValue(value) => match value { + FieldValue::Scalar(value) => { + json!({"kind": "field_value", "representation": "scalar", "value": value}) + } + FieldValue::Images(images) => json!({ + "kind": "field_value", + "representation": "images", + "media_ids": images.iter().map(|image| image.media_id.as_str()).collect::>(), + }), + FieldValue::Message(_) => json!({"kind": "field_value", "representation": "message"}), + }, + ComposePrecondition::Missing => json!({"kind": "missing"}), + } +} + +pub(crate) fn render_diagnostics(diagnostics: &[DomainDiagnostic]) -> String { + let mut lines = Vec::new(); + for diagnostic in diagnostics { + let path = diagnostic + .path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| diagnostic.address.clone()); + lines.push(format!("{path}: {}", diagnostic.message)); + for child in &diagnostic.children { + let child_path = child + .path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| child.address.clone()); + lines.push(format!(" {child_path}: {}", child.message)); + } + } + lines.join("\n") +} + +pub(crate) fn print_json_diagnostic_error( + command: &str, + context: Value, + message: &str, + diagnostics: &[DomainDiagnostic], +) { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "error": { + "schema_version": DIAGNOSTIC_SCHEMA_VERSION, + "version": DIAGNOSTIC_SCHEMA_VERSION, + "command": command, + "context": context, + "code": diagnostics.first().map(|item| item.code).unwrap_or("command_failed"), + "category": diagnostics.first().map(|item| item.category.as_str()).unwrap_or("command"), + "path": diagnostics.first().map(|item| item.path.as_ref().map(ToString::to_string).unwrap_or_else(|| item.address.clone())), + "source": diagnostics.first().and_then(|item| item.source_id.as_ref()).map(|id| id.as_str()), + "message": message, + "diagnostics": diagnostics.iter().map(diagnostic_json).collect::>(), + "details": {}, + } + })) + .unwrap() + ); +} + +pub(crate) fn print_json_diff(diff: &SemanticDiff) { + let changes = diff + .changes + .iter() + .map(|change| { + json!({ + "kind": semantic_kind_name(change.kind), + "path": change.path, + "before": change.before, + "after": change.after, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({"changes": changes})).unwrap() + ); +} + +pub(crate) fn print_human_diff(diff: &SemanticDiff) { + if diff.is_empty() { + println!("{} no semantic changes", success_marker()); + return; + } + + let suffix = if diff.changes.len() == 1 { "" } else { "s" }; + println!("{} semantic change{suffix}", diff.changes.len()); + + for change in &diff.changes { + println!(); + println!("{} {}", change_marker(change.kind), change.path); + print_change_values(change); + } +} + +pub(crate) fn print_success(message: impl AsRef, details: &[(&str, String)]) { + println!("{} {}", success_marker(), message.as_ref()); + for (label, value) in details { + println!(" {}: {}", subtle(label), value); + } +} + +pub(crate) fn print_error(message: &str) { + let error = CliError::from_message(message); + eprintln!("{}", error_text(error.human_message())); +} + +pub(crate) fn print_json_error(command: &str, message: &str) { + let error = CliError::from_message(message); + let mut value = error.json(); + if let Some(object) = value.as_object_mut() { + object.insert("command".to_owned(), json!(command)); + object.insert("context".to_owned(), Value::Null); + object.insert("diagnostics".to_owned(), json!([])); + } + println!( + "{}", + serde_json::to_string_pretty(&json!({"error": value})).unwrap() + ); +} + +pub(crate) fn deck_stats(deck: &CanonicalDeck) -> Vec<(&'static str, String)> { + vec![ + ("deck", deck.name.clone()), + ( + "notes", + deck.notes + .keys() + .filter(|id| { + deck.tombstones + .blocking(&TombstoneAddress::Note { + note_id: (*id).clone(), + }) + .is_none() + }) + .count() + .to_string(), + ), + ( + "note types", + deck.note_types + .keys() + .filter(|id| { + deck.tombstones + .blocking(&TombstoneAddress::NoteType { + note_type_id: (*id).clone(), + }) + .is_none() + }) + .count() + .to_string(), + ), + ("card templates", card_template_count(deck).to_string()), + ( + "media references", + deck.media + .keys() + .filter(|id| { + deck.tombstones + .blocking(&TombstoneAddress::MediaReference { + media_id: (*id).clone(), + }) + .is_none() + }) + .count() + .to_string(), + ), + ] +} + +pub(crate) fn semantic_kind_name(kind: SemanticChangeKind) -> &'static str { + match kind { + SemanticChangeKind::Added => "added", + SemanticChangeKind::Removed => "removed", + SemanticChangeKind::Modified => "modified", + SemanticChangeKind::Tombstoned => "tombstoned", + } +} + +pub(crate) fn package_json(package: &manifest::PackageMetadata) -> serde_json::Value { + json!({ + "id": package.id, + "version": package.version, + "base_package": package.base_package, + "compatible_base_versions": package.compatible_base_versions, + "depends_on": package.depends_on, + }) +} + +pub(crate) fn one_line(value: &str) -> String { + value.replace('\n', "\\n") +} + +fn print_change_values(change: &brain_brew_core::SemanticChange) { + match (&change.before, &change.after) { + (Some(before), Some(after)) => { + print_value_lines('-', before); + print_value_lines('+', after); + } + (Some(before), None) => print_value_lines('-', before), + (None, Some(after)) => print_value_lines('+', after), + (None, None) => println!(" {} entity", semantic_kind_name(change.kind)), + } +} + +fn print_value_lines(prefix: char, value: &str) { + let marker = match prefix { + '-' => removed_marker(), + '+' => added_marker(), + _ => prefix.to_string(), + }; + if value.contains('\n') { + println!(" {marker} |"); + for line in value.lines() { + println!(" {line}"); + } + } else if value.is_empty() { + println!(" {marker} \"\""); + } else { + println!(" {marker} {value}"); + } +} + +fn card_template_count(deck: &CanonicalDeck) -> usize { + deck.note_types + .values() + .filter(|note_type| { + deck.tombstones + .blocking(&TombstoneAddress::NoteType { + note_type_id: note_type.id.clone(), + }) + .is_none() + }) + .map(|note_type| { + note_type + .card_templates + .iter() + .filter(|template| { + deck.tombstones + .blocking(&TombstoneAddress::CardTemplate { + note_type_id: note_type.id.clone(), + template_id: template.id.clone(), + }) + .is_none() + }) + .count() + }) + .sum() +} + +fn success_marker() -> String { + color_stdout("✓", "32") +} + +fn change_marker(kind: SemanticChangeKind) -> String { + match kind { + SemanticChangeKind::Added => added_marker(), + SemanticChangeKind::Removed => removed_marker(), + SemanticChangeKind::Modified => color_stdout("~", "33"), + SemanticChangeKind::Tombstoned => color_stdout("×", "31"), + } +} + +fn added_marker() -> String { + color_stdout("+", "32") +} + +fn removed_marker() -> String { + color_stdout("-", "31") +} + +fn subtle(text: &str) -> String { + color_stdout(text, "2") +} + +fn error_text(text: &str) -> String { + color_stderr(text, "31") +} + +fn color_stdout(text: &str, code: &str) -> String { + if color_enabled(io::stdout().is_terminal()) { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } +} + +fn color_stderr(text: &str, code: &str) -> String { + if color_enabled(io::stderr().is_terminal()) { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } +} + +fn color_enabled(is_terminal: bool) -> bool { + match env::var("BRAINBREW_COLOR") { + Ok(value) if value == "always" => true, + Ok(value) if value == "never" => false, + _ => env::var_os("NO_COLOR").is_none() && is_terminal, + } +} diff --git a/crates/brain-brew-cli/src/output_transaction.rs b/crates/brain-brew-cli/src/output_transaction.rs new file mode 100644 index 0000000..44dfb79 --- /dev/null +++ b/crates/brain-brew-cli/src/output_transaction.rs @@ -0,0 +1,514 @@ +//! Clean, recoverable publication of generated output directory trees. +//! +//! A complete tree is staged beside its destination. Existing destinations are +//! moved to a backup only after staging succeeds; ordinary publication failures +//! restore that backup. A small sibling journal makes interruption state explicit +//! and recoverable on the next attempt. + +use std::collections::BTreeSet; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use brain_brew_formats::safe_relative_path::SafeRelativePath; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::workspace_mutation::nearest_existing_ancestor; + +#[derive(Debug)] +pub(crate) struct OutputArtifact { + pub(crate) path: PathBuf, + pub(crate) bytes: Vec, +} + +impl OutputArtifact { + pub(crate) fn new(path: impl Into, bytes: Vec) -> Self { + Self { + path: path.into(), + bytes, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum PublishState { + Prepared, + Published, +} + +#[derive(Debug, Deserialize, Serialize)] +struct PublishJournal { + version: u32, + target_name: String, + stage_name: String, + backup_name: String, + original_fingerprint: Option, + replacement_fingerprint: String, + state: PublishState, +} + +pub(crate) fn publish_output_tree( + output: &Path, + artifacts: Vec, + force: bool, +) -> Result<(), String> { + if artifacts.is_empty() { + return Err("refusing to publish an empty output tree".to_owned()); + } + let requested = if output.is_absolute() { + output.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| format!("cannot resolve current directory: {error}"))? + .join(output) + }; + let requested_parent = requested + .parent() + .ok_or_else(|| format!("output {} has no parent directory", output.display()))?; + let root = nearest_existing_ancestor(requested_parent)?; + let created_parents = create_missing_parents(&root, requested_parent)?; + let result = publish_in_existing_parent(&requested, artifacts, force); + if result.is_err() { + remove_empty_parents(&created_parents); + } + result +} + +fn publish_in_existing_parent( + requested: &Path, + artifacts: Vec, + force: bool, +) -> Result<(), String> { + let parent = fs::canonicalize( + requested + .parent() + .ok_or_else(|| format!("{} has no parent", requested.display()))?, + ) + .map_err(|error| format!("{}: {error}", requested.display()))?; + let target_name = requested + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("output {} must have a UTF-8 file name", requested.display()))?; + let target = parent.join(target_name); + let journal_path = parent.join(format!(".brainbrew-{target_name}.publish.json")); + recover_output_journal(&parent, &journal_path)?; + + let original_fingerprint = match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.file_type().is_dir() => { + if !force { + return Err(format!( + "refusing to overwrite existing generated output {}; pass --force to cleanly replace it", + target.display() + )); + } + Some(tree_fingerprint(&target)?) + } + Ok(metadata) => { + return Err(format!( + "refusing to replace generated output {} with unsupported type {:?}", + target.display(), + metadata.file_type() + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("{}: {error}", target.display())), + }; + + let nonce = nonce(); + let stage_name = format!(".brainbrew-{target_name}-{nonce}.stage"); + let backup_name = format!(".brainbrew-{target_name}-{nonce}.backup"); + let stage = parent.join(&stage_name); + let backup = parent.join(&backup_name); + fs::create_dir(&stage).map_err(|error| format!("{}: {error}", stage.display()))?; + let stage_result = stage_artifacts(&stage, artifacts); + if let Err(error) = stage_result { + let _ = fs::remove_dir_all(&stage); + return Err(error); + } + + let replacement_fingerprint = match tree_fingerprint(&stage) { + Ok(fingerprint) => fingerprint, + Err(error) => { + let _ = fs::remove_dir_all(&stage); + return Err(error); + } + }; + let mut journal = PublishJournal { + version: 1, + target_name: target_name.to_owned(), + stage_name, + backup_name, + original_fingerprint, + replacement_fingerprint, + state: PublishState::Prepared, + }; + if let Err(error) = write_journal(&journal_path, &journal) { + let _ = fs::remove_dir_all(&stage); + return Err(error); + } + + let publish_result: Result<(), String> = (|| { + if journal.original_fingerprint.is_some() { + fs::rename(&target, &backup).map_err(|error| { + format!( + "could not move existing generated output {} to recovery backup {}: {error}", + target.display(), + backup.display() + ) + })?; + sync_directory(&parent)?; + } + injected_failure("before-publish")?; + fs::rename(&stage, &target).map_err(|error| { + format!( + "could not publish staged output {} as {}: {error}", + stage.display(), + target.display() + ) + })?; + sync_directory(&parent)?; + journal.state = PublishState::Published; + write_journal(&journal_path, &journal)?; + injected_failure("after-publish")?; + Ok(()) + })(); + + if let Err(error) = publish_result { + if std::env::var("BRAINBREW_OUTPUT_FAIL_MODE").as_deref() == Ok("crash") { + return Err(format!( + "output publication interrupted; recoverable state is recorded at {}: {error}", + journal_path.display() + )); + } + rollback_publication(&parent, &journal)?; + remove_journal(&journal_path)?; + return Err(format!( + "output publication failed and original output was restored: {error}" + )); + } + + if backup.exists() { + fs::remove_dir_all(&backup).map_err(|error| format!("{}: {error}", backup.display()))?; + sync_directory(&parent)?; + } + remove_journal(&journal_path) +} + +fn stage_artifacts(stage: &Path, artifacts: Vec) -> Result<(), String> { + let mut seen = BTreeSet::new(); + for artifact in artifacts { + let raw = artifact.path.to_str().ok_or_else(|| { + format!( + "generated artifact path {:?} is not valid UTF-8", + artifact.path + ) + })?; + let safe = SafeRelativePath::new(raw) + .map_err(|error| format!("invalid generated artifact path {raw:?}: {error}"))?; + if !seen.insert(safe.as_str().to_owned()) { + return Err(format!("duplicate generated artifact path {raw:?}")); + } + let destination = stage.join(safe.as_path()); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .map_err(|error| format!("{}: {error}", destination.display()))?; + file.write_all(&artifact.bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("{}: {error}", destination.display()))?; + } + sync_tree(stage) +} + +fn recover_output_journal(parent: &Path, journal_path: &Path) -> Result<(), String> { + let bytes = match fs::read(journal_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("{}: {error}", journal_path.display())), + }; + let journal: PublishJournal = serde_json::from_slice(&bytes).map_err(|error| { + format!( + "{}: invalid output recovery journal: {error}", + journal_path.display() + ) + })?; + if journal.version != 1 + || !is_single_name(&journal.target_name) + || !is_single_name(&journal.stage_name) + || !is_single_name(&journal.backup_name) + { + return Err(format!( + "{}: unsafe output recovery journal", + journal_path.display() + )); + } + match journal.state { + PublishState::Prepared => rollback_publication(parent, &journal)?, + PublishState::Published => { + let target = parent.join(&journal.target_name); + let actual = optional_tree_fingerprint(&target)?; + if actual.as_deref() != Some(&journal.replacement_fingerprint) { + return Err(recovery_conflict( + &target, + &journal.replacement_fingerprint, + actual, + )); + } + let stage = parent.join(&journal.stage_name); + let backup = parent.join(&journal.backup_name); + if stage.exists() { + fs::remove_dir_all(&stage) + .map_err(|error| format!("{}: {error}", stage.display()))?; + } + if backup.exists() { + fs::remove_dir_all(&backup) + .map_err(|error| format!("{}: {error}", backup.display()))?; + } + } + } + remove_journal(journal_path) +} + +fn rollback_publication(parent: &Path, journal: &PublishJournal) -> Result<(), String> { + let target = parent.join(&journal.target_name); + let stage = parent.join(&journal.stage_name); + let backup = parent.join(&journal.backup_name); + let current = optional_tree_fingerprint(&target)?; + if backup.exists() { + if current.is_some() + && current.as_deref() != Some(&journal.replacement_fingerprint) + && current.as_deref() != journal.original_fingerprint.as_deref() + { + return Err(recovery_conflict( + &target, + &journal.replacement_fingerprint, + current, + )); + } + if target.exists() { + fs::remove_dir_all(&target) + .map_err(|error| format!("{}: {error}", target.display()))?; + } + fs::rename(&backup, &target) + .map_err(|error| format!("{} -> {}: {error}", backup.display(), target.display()))?; + } else if journal.original_fingerprint.is_none() { + if let Some(actual) = current { + if actual != journal.replacement_fingerprint { + return Err(recovery_conflict( + &target, + &journal.replacement_fingerprint, + Some(actual), + )); + } + fs::remove_dir_all(&target) + .map_err(|error| format!("{}: {error}", target.display()))?; + } + } else if current.as_deref() != journal.original_fingerprint.as_deref() { + return Err(recovery_conflict( + &target, + journal + .original_fingerprint + .as_deref() + .expect("original fingerprint exists"), + current, + )); + } + if stage.exists() { + fs::remove_dir_all(&stage).map_err(|error| format!("{}: {error}", stage.display()))?; + } + sync_directory(parent) +} + +fn write_journal(path: &Path, journal: &PublishJournal) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(journal).map_err(|error| error.to_string())?; + let temp = path.with_extension("json.next"); + if temp.exists() { + fs::remove_file(&temp).map_err(|error| format!("{}: {error}", temp.display()))?; + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(|error| format!("{}: {error}", temp.display()))?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .map_err(|error| format!("{}: {error}", temp.display()))?; + fs::rename(&temp, path).map_err(|error| format!("{}: {error}", path.display()))?; + sync_directory( + path.parent() + .ok_or_else(|| format!("{} has no parent", path.display()))?, + ) +} + +fn remove_journal(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| format!("{} has no parent", path.display()))?, + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("{}: {error}", path.display())), + } +} + +fn create_missing_parents(root: &Path, requested: &Path) -> Result, String> { + let mut missing = Vec::new(); + let mut cursor = requested; + while !cursor.exists() { + missing.push(cursor.to_path_buf()); + cursor = cursor + .parent() + .ok_or_else(|| format!("{} has no existing ancestor", requested.display()))?; + } + let ancestor = + fs::canonicalize(cursor).map_err(|error| format!("{}: {error}", cursor.display()))?; + if !ancestor.starts_with(root) { + return Err(format!( + "output parent {} escapes selected output root {} through {}", + requested.display(), + root.display(), + ancestor.display() + )); + } + missing.reverse(); + let mut created = Vec::new(); + for path in missing { + if let Err(error) = fs::create_dir(&path) { + remove_empty_parents(&created); + return Err(format!("{}: {error}", path.display())); + } + created.push(path); + } + Ok(created) +} + +fn remove_empty_parents(paths: &[PathBuf]) { + for path in paths.iter().rev() { + let _ = fs::remove_dir(path); + } +} + +fn sync_tree(path: &Path) -> Result<(), String> { + let mut directories = vec![path.to_path_buf()]; + for entry in fs::read_dir(path).map_err(|error| format!("{}: {error}", path.display()))? { + let entry = entry.map_err(|error| format!("{}: {error}", path.display()))?; + if entry + .file_type() + .map_err(|error| format!("{}: {error}", entry.path().display()))? + .is_dir() + { + sync_tree(&entry.path())?; + directories.push(entry.path()); + } + } + for directory in directories.into_iter().rev() { + sync_directory(&directory)?; + } + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<(), String> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("{}: {error}", path.display())) +} + +fn optional_tree_fingerprint(path: &Path) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => tree_fingerprint(path).map(Some), + Ok(metadata) => Err(format!( + "output recovery path {} has unsupported type {:?}", + path.display(), + metadata.file_type() + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("{}: {error}", path.display())), + } +} + +fn tree_fingerprint(root: &Path) -> Result { + let mut hasher = Sha256::new(); + hash_directory(root, Path::new(""), &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} + +fn hash_directory(root: &Path, relative: &Path, hasher: &mut Sha256) -> Result<(), String> { + let directory = root.join(relative); + let mut entries = fs::read_dir(&directory) + .map_err(|error| format!("{}: {error}", directory.display()))? + .collect::, _>>() + .map_err(|error| format!("{}: {error}", directory.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name().into_string().map_err(|_| { + format!( + "generated output contains a non-UTF-8 entry under {}", + directory.display() + ) + })?; + let child = relative.join(name); + let child_text = child + .to_str() + .ok_or_else(|| format!("generated output path {:?} is not UTF-8", child))?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("{}: {error}", entry.path().display()))?; + if metadata.file_type().is_dir() { + hasher.update(b"directory\0"); + hasher.update(child_text.as_bytes()); + hasher.update(b"\0"); + hash_directory(root, &child, hasher)?; + } else if metadata.file_type().is_file() { + let bytes = fs::read(entry.path()) + .map_err(|error| format!("{}: {error}", entry.path().display()))?; + hasher.update(b"file\0"); + hasher.update(child_text.as_bytes()); + hasher.update(b"\0"); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } else { + return Err(format!( + "refusing generated output tree {}: entry {} has unsupported type {:?}", + root.display(), + entry.path().display(), + metadata.file_type() + )); + } + } + Ok(()) +} + +fn recovery_conflict(path: &Path, expected: &str, actual: Option) -> String { + format!( + "output recovery conflict at {}: expected tree fingerprint {}, found {} (backup and journal were retained)", + path.display(), + expected, + actual.as_deref().unwrap_or("absent") + ) +} + +fn injected_failure(point: &str) -> Result<(), String> { + if std::env::var("BRAINBREW_OUTPUT_FAIL_POINT").as_deref() == Ok(point) { + Err(format!("injected output failure at {point}")) + } else { + Ok(()) + } +} + +fn is_single_name(value: &str) -> bool { + Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value) +} + +fn nonce() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{}-{nanos}", std::process::id()) +} diff --git a/crates/brain-brew-cli/src/overlay_draft.rs b/crates/brain-brew-cli/src/overlay_draft.rs new file mode 100644 index 0000000..05aefa8 --- /dev/null +++ b/crates/brain-brew-cli/src/overlay_draft.rs @@ -0,0 +1,367 @@ +use std::collections::BTreeMap; + +use brain_brew_core::{ + AdapterIdChange, AdapterIds, CanonicalDeck, CardTemplateChange, ChangeIntent, DeckChange, + ExpectedBase, FieldChange, FieldDefinitionChange, MediaChange, NoteChange, NoteTypeChange, + Overlay, OverlayKind, PropertyChange, StableId, TagChange, fingerprint_media_reference, + fingerprint_note, fingerprint_note_type, +}; + +pub(crate) fn draft_overlay_from_diff( + left: &CanonicalDeck, + right: &CanonicalDeck, + id: StableId, + kind: OverlayKind, +) -> Result { + let mut overlay = Overlay { + id, + kind, + translations: None, + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + draft_deck_changes(left, right, &mut overlay); + draft_note_type_changes(left, right, &mut overlay)?; + draft_note_changes(left, right, &mut overlay); + draft_media_changes(left, right, &mut overlay); + + if overlay.deck_change.is_none() + && overlay.note_changes.is_empty() + && overlay.note_type_changes.is_empty() + && overlay.media_changes.is_empty() + && !left.semantic_diff(right).is_empty() + { + return Err( + "diff --as-overlay currently supports deck name/description, adapter IDs, tags, media references, note additions/removals, and existing note field changes" + .to_owned(), + ); + } + + Ok(overlay) +} + +fn draft_deck_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + let mut deck_change = DeckChange { + name: None, + description: None, + variables: BTreeMap::new(), + adapter_ids: adapter_id_changes(&left.adapter_ids, &right.adapter_ids), + }; + if left.name != right.name { + deck_change.name = Some(replace_property_change(&left.name, &right.name)); + } + if left.description != right.description { + deck_change.description = Some(replace_property_change( + &left.description, + &right.description, + )); + } + if deck_change.name.is_some() + || deck_change.description.is_some() + || !deck_change.adapter_ids.is_empty() + { + overlay.deck_change = Some(deck_change); + } +} + +fn draft_note_type_changes( + left: &CanonicalDeck, + right: &CanonicalDeck, + overlay: &mut Overlay, +) -> Result<(), String> { + if let Some(note_type_id) = left + .note_types + .keys() + .find(|note_type_id| !right.note_types.contains_key(*note_type_id)) + { + return Err(format!( + "diff --as-overlay cannot yet order removal of note type {note_type_id} after dependent note removals" + )); + } + + for (note_type_id, right_note_type) in &right.note_types { + if !left.note_types.contains_key(note_type_id) { + overlay.note_type_changes.insert( + note_type_id.clone(), + NoteTypeChange { + intent: ChangeIntent::Add, + note_type: Some(right_note_type.clone()), + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + ); + } + } + + for (note_type_id, left_note_type) in &left.note_types { + let Some(right_note_type) = right.note_types.get(note_type_id) else { + continue; + }; + let adapter_ids = + adapter_id_changes(&left_note_type.adapter_ids, &right_note_type.adapter_ids); + let mut right_without_adapter_changes = right_note_type.clone(); + right_without_adapter_changes.adapter_ids = left_note_type.adapter_ids.clone(); + if &right_without_adapter_changes != left_note_type { + overlay.note_type_changes.insert( + note_type_id.clone(), + NoteTypeChange { + intent: ChangeIntent::Replace, + note_type: Some(right_note_type.clone()), + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityFingerprint(fingerprint_note_type( + left_note_type, + ))), + }, + ); + } else if !adapter_ids.is_empty() { + overlay.note_type_changes.insert( + note_type_id.clone(), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::::new(), + card_templates: BTreeMap::::new(), + adapter_ids, + expected_base: None, + }, + ); + } + } + Ok(()) +} + +fn draft_note_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + for (note_id, left_note) in &left.notes { + if !right.notes.contains_key(note_id) { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Remove, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityFingerprint(fingerprint_note( + left_note, + ))), + }, + ); + continue; + } + + let right_note = &right.notes[note_id]; + let can_be_sparse = left_note.note_type_id == right_note.note_type_id + && left_note.variables == right_note.variables + && left_note.fields.keys().eq(right_note.fields.keys()); + if !can_be_sparse { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Replace, + note: Some(right_note.clone()), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityFingerprint(fingerprint_note( + left_note, + ))), + }, + ); + continue; + } + let mut fields = BTreeMap::new(); + for (field_id, left_value) in &left_note.fields { + let Some(right_value) = right_note.fields.get(field_id) else { + continue; + }; + if left_value != right_value { + fields.insert( + field_id.clone(), + FieldChange { + intent: ChangeIntent::Replace, + value: Some(right_value.clone()), + expected_base: Some(ExpectedBase::FieldValue(left_value.clone())), + }, + ); + } + } + + let mut tags = BTreeMap::new(); + for tag in left_note.tags.difference(&right_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Remove, + expected_base: Some(ExpectedBase::Value(tag.clone())), + }, + ); + } + for tag in right_note.tags.difference(&left_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Add, + expected_base: None, + }, + ); + } + + let adapter_ids = adapter_id_changes(&left_note.adapter_ids, &right_note.adapter_ids); + + if !fields.is_empty() || !tags.is_empty() || !adapter_ids.is_empty() { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields, + tags, + adapter_ids, + expected_base: None, + }, + ); + } + } + + for (note_id, right_note) in &right.notes { + if !left.notes.contains_key(note_id) { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Add, + note: Some(right_note.clone()), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + ); + } + } +} + +fn draft_media_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + for (media_id, left_media) in &left.media { + match right.media.get(media_id) { + Some(right_media) if left_media != right_media => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Replace, + media: Some(right_media.clone()), + expected_base: Some(ExpectedBase::EntityFingerprint( + fingerprint_media_reference(left_media), + )), + }, + ); + } + Some(_) => {} + None => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Remove, + media: None, + expected_base: Some(ExpectedBase::EntityFingerprint( + fingerprint_media_reference(left_media), + )), + }, + ); + } + } + } + + for (media_id, right_media) in &right.media { + if !left.media.contains_key(media_id) { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Add, + media: Some(right_media.clone()), + expected_base: None, + }, + ); + } + } +} + +fn adapter_id_changes(left: &AdapterIds, right: &AdapterIds) -> BTreeMap { + let left = left + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let right = right + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let mut changes = BTreeMap::new(); + + for (key, left_value) in &left { + match right.get(key) { + Some(right_value) if left_value != right_value => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some(right_value.clone()), + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + Some(_) => {} + None => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Remove, + value: None, + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + } + } + + for (key, right_value) in &right { + if !left.contains_key(key) { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Add, + value: Some(right_value.clone()), + expected_base: None, + }, + ); + } + } + + changes +} + +fn replace_property_change(before: &str, after: &str) -> PropertyChange { + PropertyChange { + intent: ChangeIntent::Replace, + value: Some(after.to_owned()), + expected_base: Some(ExpectedBase::Value(before.to_owned())), + } +} diff --git a/crates/brain-brew-cli/src/package_resolver.rs b/crates/brain-brew-cli/src/package_resolver.rs new file mode 100644 index 0000000..fde2d97 --- /dev/null +++ b/crates/brain-brew-cli/src/package_resolver.rs @@ -0,0 +1,979 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::manifest; +use brain_brew_formats::package_semver; +use brain_brew_formats::safe_relative_path::SafeRelativePath; + +pub(crate) const DEFAULT_DISCOVERY_MAX_DEPTH: usize = 32; +pub(crate) const DEFAULT_DISCOVERY_MAX_ENTRIES: usize = 100_000; +pub(crate) const DEFAULT_DISCOVERY_MAX_MANIFESTS: usize = 1_000; + +const MAX_DISCOVERY_DEPTH_OVERRIDE: usize = 256; +const MAX_DISCOVERY_ENTRIES_OVERRIDE: usize = 10_000_000; +const MAX_DISCOVERY_MANIFESTS_OVERRIDE: usize = 100_000; + +/// One validated policy for every recursive package-root walk. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveryPolicy { + max_depth: usize, + max_entries: usize, + max_manifests: usize, + depth_overridden: bool, + entries_overridden: bool, + manifests_overridden: bool, + ignores: Vec, +} + +impl Default for DiscoveryPolicy { + fn default() -> Self { + Self { + max_depth: DEFAULT_DISCOVERY_MAX_DEPTH, + max_entries: DEFAULT_DISCOVERY_MAX_ENTRIES, + max_manifests: DEFAULT_DISCOVERY_MAX_MANIFESTS, + depth_overridden: false, + entries_overridden: false, + manifests_overridden: false, + ignores: Vec::new(), + } + } +} + +impl DiscoveryPolicy { + pub(crate) fn set_max_depth(&mut self, raw: &str) -> Result<(), String> { + reject_duplicate_override("--discovery-max-depth", self.depth_overridden)?; + self.max_depth = parse_limit("--discovery-max-depth", raw, MAX_DISCOVERY_DEPTH_OVERRIDE)?; + self.depth_overridden = true; + Ok(()) + } + + pub(crate) fn set_max_entries(&mut self, raw: &str) -> Result<(), String> { + reject_duplicate_override("--discovery-max-entries", self.entries_overridden)?; + self.max_entries = parse_limit( + "--discovery-max-entries", + raw, + MAX_DISCOVERY_ENTRIES_OVERRIDE, + )?; + self.entries_overridden = true; + Ok(()) + } + + pub(crate) fn set_max_manifests(&mut self, raw: &str) -> Result<(), String> { + reject_duplicate_override("--discovery-max-manifests", self.manifests_overridden)?; + self.max_manifests = parse_limit( + "--discovery-max-manifests", + raw, + MAX_DISCOVERY_MANIFESTS_OVERRIDE, + )?; + self.manifests_overridden = true; + Ok(()) + } + + pub(crate) fn add_ignore(&mut self, raw: &str) -> Result<(), String> { + let safe = SafeRelativePath::new(raw).map_err(|error| { + format!( + "--package-ignore value {raw:?} is not a safe package-root-relative path/pattern: {error}" + ) + })?; + self.ignores.push(DiscoveryIgnore::new(safe)); + self.ignores.sort(); + self.ignores.dedup(); + Ok(()) + } +} + +pub(crate) fn apply_discovery_option( + flag: &str, + value: &str, + policy: &mut DiscoveryPolicy, +) -> Result { + match flag { + "--discovery-max-depth" => policy.set_max_depth(value)?, + "--discovery-max-entries" => policy.set_max_entries(value)?, + "--discovery-max-manifests" => policy.set_max_manifests(value)?, + "--package-ignore" => policy.add_ignore(value)?, + _ => return Ok(false), + } + Ok(true) +} + +fn reject_duplicate_override(flag: &str, already_set: bool) -> Result<(), String> { + if already_set { + Err(format!("duplicate argument {flag:?}")) + } else { + Ok(()) + } +} + +fn parse_limit(flag: &str, raw: &str, maximum: usize) -> Result { + let value = raw + .parse::() + .map_err(|_| format!("{flag} requires a positive decimal integer, found {raw:?}"))?; + if value == 0 { + return Err(format!("{flag} must be greater than zero")); + } + if value > maximum { + return Err(format!( + "{flag} value {value} is effectively unbounded; maximum supported override is {maximum}" + )); + } + Ok(value) +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct DiscoveryIgnore { + display: String, + components: Vec, +} + +impl DiscoveryIgnore { + fn new(path: SafeRelativePath) -> Self { + Self { + display: path.as_str().to_owned(), + components: path.as_str().split('/').map(str::to_owned).collect(), + } + } + + fn matches(&self, relative: &[String]) -> bool { + match_components(&self.components, relative) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct DiscoveryStats { + pub(crate) roots_inspected: usize, + pub(crate) entries_inspected: usize, + pub(crate) directories_inspected: usize, + pub(crate) regular_files_inspected: usize, + pub(crate) manifests_found: usize, + pub(crate) built_in_pruned: usize, + pub(crate) configured_pruned: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveryResult { + pub(crate) manifests: Vec, + pub(crate) stats: DiscoveryStats, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveryBudget { + Depth, + Entries, + Manifests, +} + +impl DiscoveryBudget { + fn name(self) -> &'static str { + match self { + Self::Depth => "depth", + Self::Entries => "entries", + Self::Manifests => "manifests", + } + } + + fn flag(self) -> &'static str { + match self { + Self::Depth => "--discovery-max-depth", + Self::Entries => "--discovery-max-entries", + Self::Manifests => "--discovery-max-manifests", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveryError { + BudgetExceeded { + budget: DiscoveryBudget, + package_root: PathBuf, + current_path: PathBuf, + consumed: usize, + limit: usize, + }, + Filesystem { + package_root: PathBuf, + current_path: PathBuf, + operation: &'static str, + message: String, + }, + RejectedEntry { + package_root: PathBuf, + current_path: PathBuf, + kind: &'static str, + }, + IdentityReuse { + package_root: PathBuf, + current_path: PathBuf, + first_path: PathBuf, + }, + NonUtf8Entry { + package_root: PathBuf, + current_path: PathBuf, + }, +} + +impl fmt::Display for DiscoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BudgetExceeded { + budget, + package_root, + current_path, + consumed, + limit, + } => write!( + formatter, + "package discovery budget exceeded: budget={} package_root={} current_path={} consumed={} limit={}; raise {} explicitly after reviewing the package root", + budget.name(), + package_root.display(), + current_path.display(), + consumed, + limit, + budget.flag() + ), + Self::Filesystem { + package_root, + current_path, + operation, + message, + } => write!( + formatter, + "package discovery failed closed: package_root={} current_path={} operation={operation}: {message}", + package_root.display(), + current_path.display() + ), + Self::RejectedEntry { + package_root, + current_path, + kind, + } => write!( + formatter, + "package discovery rejected {kind} {} under package root {}; discovery never follows links and accepts only regular files/directories outside pruned trees", + current_path.display(), + package_root.display() + ), + Self::IdentityReuse { + package_root, + current_path, + first_path, + } => write!( + formatter, + "package discovery rejected reused directory identity at {} under package root {}; it was already inspected as {} (possible alias, replacement, or filesystem cycle)", + current_path.display(), + package_root.display(), + first_path.display() + ), + Self::NonUtf8Entry { + package_root, + current_path, + } => write!( + formatter, + "package discovery rejected non-UTF-8 entry under package root {} while inspecting {}; package paths must have deterministic portable names", + package_root.display(), + current_path.display() + ), + } + } +} + +impl std::error::Error for DiscoveryError {} + +/// Discover manifests once for the registry planner, in stable root/relative-path order. +pub(crate) fn discover_package_manifests( + roots: &[PathBuf], + policy: &DiscoveryPolicy, +) -> Result { + let mut traversal_roots = Vec::new(); + for requested in roots { + let authorized = authorize_package_root(requested)?; + traversal_roots.push((authorized, requested.clone())); + } + traversal_roots.sort(); + + let mut state = DiscoveryState { + policy, + stats: DiscoveryStats::default(), + manifests: Vec::new(), + directory_identities: BTreeMap::new(), + }; + for (authorized, requested) in traversal_roots { + state.stats.roots_inspected += 1; + state.inspect_entry_budget(&requested, &authorized)?; + state.walk_directory(&requested, &authorized, &authorized, &[], 0)?; + } + state.manifests.sort_by(|left, right| { + left.root + .cmp(&right.root) + .then_with(|| left.relative.cmp(&right.relative)) + }); + Ok(DiscoveryResult { + manifests: state + .manifests + .into_iter() + .map(|manifest| manifest.path) + .collect(), + stats: state.stats, + }) +} + +fn authorize_package_root(requested: &Path) -> Result { + let absolute = std::path::absolute(requested).map_err(|error| DiscoveryError::Filesystem { + package_root: requested.to_path_buf(), + current_path: requested.to_path_buf(), + operation: "make package root absolute", + message: error.to_string(), + })?; + let mut normalized = PathBuf::new(); + for component in absolute.components() { + use std::path::Component; + match component { + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(DiscoveryError::Filesystem { + package_root: requested.to_path_buf(), + current_path: absolute, + operation: "normalize package root", + message: "parent component escapes the filesystem root".to_owned(), + }); + } + } + } + } + + let mut current = PathBuf::new(); + for component in normalized.components() { + current.push(component.as_os_str()); + let metadata = + fs::symlink_metadata(¤t).map_err(|error| DiscoveryError::Filesystem { + package_root: requested.to_path_buf(), + current_path: current.clone(), + operation: "inspect package root component", + message: error.to_string(), + })?; + if metadata.file_type().is_symlink() { + return Err(DiscoveryError::RejectedEntry { + package_root: requested.to_path_buf(), + current_path: current, + kind: "symlink", + }); + } + } + let metadata = + fs::symlink_metadata(&normalized).map_err(|error| DiscoveryError::Filesystem { + package_root: requested.to_path_buf(), + current_path: normalized.clone(), + operation: "inspect package root", + message: error.to_string(), + })?; + let kind = file_kind(&metadata); + if kind != "directory" { + return Err(DiscoveryError::RejectedEntry { + package_root: requested.to_path_buf(), + current_path: normalized, + kind, + }); + } + Ok(normalized) +} + +struct DiscoveredManifest { + root: PathBuf, + relative: String, + path: PathBuf, +} + +struct DiscoveryState<'a> { + policy: &'a DiscoveryPolicy, + stats: DiscoveryStats, + manifests: Vec, + directory_identities: BTreeMap, +} + +impl DiscoveryState<'_> { + fn walk_directory( + &mut self, + package_root: &Path, + canonical_root: &Path, + directory: &Path, + relative: &[String], + depth: usize, + ) -> Result<(), DiscoveryError> { + if depth > self.policy.max_depth { + return Err(self.budget_error( + DiscoveryBudget::Depth, + package_root, + directory, + depth, + self.policy.max_depth, + )); + } + let before = self.metadata(package_root, directory, "inspect directory")?; + let kind = file_kind(&before); + if kind != "directory" { + return Err(DiscoveryError::RejectedEntry { + package_root: package_root.to_path_buf(), + current_path: directory.to_path_buf(), + kind, + }); + } + let identity = file_identity(directory, &before); + if let Some(first_path) = self + .directory_identities + .insert(identity, directory.to_path_buf()) + { + return Err(DiscoveryError::IdentityReuse { + package_root: package_root.to_path_buf(), + current_path: directory.to_path_buf(), + first_path, + }); + } + self.stats.directories_inspected += 1; + + let iterator = fs::read_dir(directory).map_err(|error| DiscoveryError::Filesystem { + package_root: package_root.to_path_buf(), + current_path: directory.to_path_buf(), + operation: "read directory", + message: error.to_string(), + })?; + let mut entries = Vec::new(); + for entry in iterator { + let entry = entry.map_err(|error| DiscoveryError::Filesystem { + package_root: package_root.to_path_buf(), + current_path: directory.to_path_buf(), + operation: "read directory entry", + message: error.to_string(), + })?; + let path = entry.path(); + self.inspect_entry_budget(package_root, &path)?; + let name = + entry + .file_name() + .into_string() + .map_err(|_| DiscoveryError::NonUtf8Entry { + package_root: package_root.to_path_buf(), + current_path: path.clone(), + })?; + entries.push((name, path)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + + let after = self.metadata(package_root, directory, "recheck directory")?; + if file_kind(&after) != "directory" || file_identity(directory, &after) != identity { + return Err(DiscoveryError::Filesystem { + package_root: package_root.to_path_buf(), + current_path: directory.to_path_buf(), + operation: "recheck directory identity", + message: "directory was replaced during traversal".to_owned(), + }); + } + + for (name, path) in entries { + let mut child_relative = relative.to_vec(); + child_relative.push(name.clone()); + if built_in_ignore(&name) { + self.stats.built_in_pruned += 1; + continue; + } + if self + .policy + .ignores + .iter() + .any(|ignore| ignore.matches(&child_relative)) + { + self.stats.configured_pruned += 1; + continue; + } + + let metadata = self.metadata(package_root, &path, "inspect entry")?; + match file_kind(&metadata) { + "directory" => self.walk_directory( + package_root, + canonical_root, + &path, + &child_relative, + depth + 1, + )?, + "regular file" => { + self.stats.regular_files_inspected += 1; + if name == "brainbrew.yaml" { + self.stats.manifests_found += 1; + if self.stats.manifests_found > self.policy.max_manifests { + return Err(self.budget_error( + DiscoveryBudget::Manifests, + package_root, + &path, + self.stats.manifests_found, + self.policy.max_manifests, + )); + } + let relative = child_relative.join("/"); + // The manifest result crosses into package path authorization, so require + // the same portable relative syntax used by manifest-owned paths. + SafeRelativePath::new(&relative).map_err(|error| { + DiscoveryError::Filesystem { + package_root: package_root.to_path_buf(), + current_path: path.clone(), + operation: "authorize discovered manifest path", + message: error.to_string(), + } + })?; + self.manifests.push(DiscoveredManifest { + root: canonical_root.to_path_buf(), + relative, + path, + }); + } + } + kind => { + return Err(DiscoveryError::RejectedEntry { + package_root: package_root.to_path_buf(), + current_path: path, + kind, + }); + } + } + } + Ok(()) + } + + fn inspect_entry_budget( + &mut self, + package_root: &Path, + current_path: &Path, + ) -> Result<(), DiscoveryError> { + self.stats.entries_inspected += 1; + if self.stats.entries_inspected > self.policy.max_entries { + return Err(self.budget_error( + DiscoveryBudget::Entries, + package_root, + current_path, + self.stats.entries_inspected, + self.policy.max_entries, + )); + } + Ok(()) + } + + fn metadata( + &self, + package_root: &Path, + path: &Path, + operation: &'static str, + ) -> Result { + fs::symlink_metadata(path).map_err(|error| DiscoveryError::Filesystem { + package_root: package_root.to_path_buf(), + current_path: path.to_path_buf(), + operation, + message: error.to_string(), + }) + } + + fn budget_error( + &self, + budget: DiscoveryBudget, + package_root: &Path, + current_path: &Path, + consumed: usize, + limit: usize, + ) -> DiscoveryError { + DiscoveryError::BudgetExceeded { + budget, + package_root: package_root.to_path_buf(), + current_path: current_path.to_path_buf(), + consumed, + limit, + } + } +} + +fn built_in_ignore(name: &str) -> bool { + matches!( + name, + ".git" + | ".jj" + | ".hg" + | ".svn" + | ".devenv" + | ".direnv" + | "target" + | "build" + | "output" + | "outputs" + | "dist" + | "site" + | "_site" + | "node_modules" + | ".docusaurus" + | ".cache" + | ".brainbrew-cache" + | ".brainbrew-transactions" + | "result" + ) || name.starts_with("result-") + || (name.starts_with(".brainbrew-") + && (name.ends_with(".stage") || name.ends_with(".backup"))) +} + +fn match_components(pattern: &[String], path: &[String]) -> bool { + match pattern.split_first() { + None => path.is_empty(), + Some((head, tail)) if head == "**" => { + match_components(tail, path) + || (!path.is_empty() && match_components(pattern, &path[1..])) + } + Some((head, tail)) => { + let Some((candidate, rest)) = path.split_first() else { + return false; + }; + component_glob_matches(head, candidate) && match_components(tail, rest) + } + } +} + +fn component_glob_matches(pattern: &str, candidate: &str) -> bool { + let pattern = pattern.chars().collect::>(); + let candidate = candidate.chars().collect::>(); + let mut matches = vec![vec![false; candidate.len() + 1]; pattern.len() + 1]; + matches[0][0] = true; + for pattern_index in 0..pattern.len() { + for candidate_index in 0..=candidate.len() { + if !matches[pattern_index][candidate_index] { + continue; + } + match pattern[pattern_index] { + '*' => { + matches[pattern_index + 1][candidate_index] = true; + if candidate_index < candidate.len() { + matches[pattern_index][candidate_index + 1] = true; + } + } + '?' if candidate_index < candidate.len() => { + matches[pattern_index + 1][candidate_index + 1] = true; + } + literal + if candidate_index < candidate.len() + && literal == candidate[candidate_index] => + { + matches[pattern_index + 1][candidate_index + 1] = true; + } + _ => {} + } + } + } + matches[pattern.len()][candidate.len()] +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct FileIdentity(u64, u64); + +#[cfg(unix)] +fn file_identity(_path: &Path, metadata: &fs::Metadata) -> FileIdentity { + use std::os::unix::fs::MetadataExt; + FileIdentity(metadata.dev(), metadata.ino()) +} + +#[cfg(not(unix))] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct FileIdentity(PathBuf); + +#[cfg(not(unix))] +fn file_identity(path: &Path, _metadata: &fs::Metadata) -> FileIdentity { + FileIdentity(path.to_path_buf()) +} + +fn file_kind(metadata: &fs::Metadata) -> &'static str { + let file_type = metadata.file_type(); + if file_type.is_symlink() { + "symlink" + } else if file_type.is_dir() { + "directory" + } else if file_type.is_file() { + "regular file" + } else { + special_file_kind(&file_type) + } +} + +#[cfg(unix)] +fn special_file_kind(file_type: &fs::FileType) -> &'static str { + use std::os::unix::fs::FileTypeExt; + if file_type.is_socket() { + "unsupported socket" + } else if file_type.is_fifo() { + "unsupported fifo" + } else if file_type.is_char_device() || file_type.is_block_device() { + "unsupported device" + } else { + "unsupported special entry" + } +} + +#[cfg(not(unix))] +fn special_file_kind(_file_type: &fs::FileType) -> &'static str { + "unsupported special entry" +} + +pub(crate) fn validate_package_dependencies( + packages: &[(&PathBuf, &manifest::FederatedDeckManifest)], +) -> Result<(), String> { + let mut by_id = BTreeMap::new(); + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + if let Some(previous) = by_id.insert(package.id.clone(), (*path, package)) { + let conflict = if previous.1.version == package.version { + "duplicate package identity" + } else { + "conflicting package versions" + }; + return Err(format!( + "{conflict} {}: {} in {} and {} in {}", + package.id, + previous.1.version, + previous.0.display(), + package.version, + path.display() + )); + } + } + + let mut graph = BTreeMap::>::new(); + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + let mut dependencies = Vec::new(); + for declaration in &package.depends_on { + let dependency = package_semver::parse_exact_dependency(declaration).map_err(|reason| { + format!( + "invalid package dependency {declaration:?} declared by {}@{} in {}: {reason}", + package.id, + package.version, + path.display() + ) + })?; + let Some((dependency_path, dependency_package)) = by_id.get(&dependency.id) else { + return Err(format!( + "package dependency {}@{} required by {}@{} in {} was not found", + dependency.id, + dependency.version, + package.id, + package.version, + path.display() + )); + }; + if dependency_package.version != dependency.version { + return Err(format!( + "package dependency {}@{} required by {}@{} in {} resolved to version {} in {}", + dependency.id, + dependency.version, + package.id, + package.version, + path.display(), + dependency_package.version, + dependency_path.display() + )); + } + dependencies.push(DependencyEdge { + target: dependency.id, + declaration: declaration.clone(), + }); + } + + if let Some(base_package) = &package.base_package { + let Some((base_path, base)) = by_id.get(base_package) else { + // This should also be diagnosed by the exact dependency pass, + // but retain a specific fail-closed guard for constructed data. + return Err(format!( + "base package {base_package} required by {}@{} in {} was not found", + package.id, + package.version, + path.display() + )); + }; + let compatible = package_semver::requirements_match( + &package.compatible_base_versions, + &base.version, + ) + .map_err(|error| { + format!( + "invalid compatible_base_versions declared by {}@{} in {}: {error}", + package.id, + package.version, + path.display() + ) + })?; + if !compatible { + return Err(format!( + "base package {}@{} in {} is incompatible with {}@{} in {}; compatible_base_versions requires one of [{}]", + base.id, + base.version, + base_path.display(), + package.id, + package.version, + path.display(), + package.compatible_base_versions.join(" OR ") + )); + } + } + graph.insert(package.id.clone(), dependencies); + } + + let mut complete = BTreeSet::new(); + let mut active = Vec::new(); + for package in graph.keys() { + visit_package(package, &graph, &by_id, &mut complete, &mut active)?; + } + Ok(()) +} + +#[derive(Clone)] +struct DependencyEdge { + target: String, + declaration: String, +} + +fn visit_package( + package: &str, + graph: &BTreeMap>, + packages: &BTreeMap, + complete: &mut BTreeSet, + active: &mut Vec<(String, Option)>, +) -> Result<(), String> { + if complete.contains(package) { + return Ok(()); + } + if let Some(index) = active + .iter() + .position(|(candidate, _)| candidate == package) + { + let cycle = &active[index..]; + let mut trace = String::from("package dependency cycle:"); + for (id, incoming) in cycle { + let (path, metadata) = packages[id]; + trace.push_str(&format!( + "\n {}@{} ({})", + metadata.id, + metadata.version, + path.display() + )); + let edge = incoming + .as_ref() + .expect("a dependency cycle has an outgoing edge"); + trace.push_str(&format!(" --depends_on {edge}-->")); + } + let (path, metadata) = packages[package]; + trace.push_str(&format!( + "\n {}@{} ({})", + metadata.id, + metadata.version, + path.display() + )); + return Err(trace); + } + active.push((package.to_owned(), None)); + for dependency in graph.get(package).into_iter().flatten() { + if let Some(last) = active.last_mut() { + last.1 = Some(dependency.declaration.clone()); + } + visit_package(&dependency.target, graph, packages, complete, active)?; + } + active.pop(); + complete.insert(package.to_owned()); + Ok(()) +} + +#[cfg(test)] +mod discovery_tests { + use super::*; + + #[test] + fn moderate_tree_has_stable_results_and_exact_visit_prune_counts() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("package"); + fs::create_dir_all(&package).unwrap(); + fs::write(package.join("brainbrew.yaml"), "base: deck.yaml\n").unwrap(); + for index in 0..200 { + fs::write(package.join(format!("visible-{index:03}.txt")), "visible").unwrap(); + } + let generated = temp.path().join("target/generated"); + fs::create_dir_all(&generated).unwrap(); + for index in 0..500 { + fs::write(generated.join(format!("ignored-{index:03}.txt")), "ignored").unwrap(); + } + + let roots = vec![temp.path().to_path_buf()]; + let first = discover_package_manifests(&roots, &DiscoveryPolicy::default()).unwrap(); + let second = discover_package_manifests(&roots, &DiscoveryPolicy::default()).unwrap(); + assert_eq!(first, second); + assert_eq!(first.manifests, vec![package.join("brainbrew.yaml")]); + assert_eq!(first.stats.roots_inspected, 1); + assert_eq!(first.stats.entries_inspected, 204); + assert_eq!(first.stats.directories_inspected, 2); + assert_eq!(first.stats.regular_files_inspected, 201); + assert_eq!(first.stats.manifests_found, 1); + assert_eq!(first.stats.built_in_pruned, 1); + assert_eq!(first.stats.configured_pruned, 0); + } + + #[test] + fn configured_globs_match_whole_components_and_builtins_take_precedence() { + let mut policy = DiscoveryPolicy::default(); + policy.add_ignore("vendor/**/generated-?").unwrap(); + policy.add_ignore(".git/**").unwrap(); + let patterns = &policy.ignores; + assert!(patterns[1].matches(&[ + "vendor".to_owned(), + "nested".to_owned(), + "generated-a".to_owned(), + ])); + assert!(!patterns[1].matches(&[ + "vendor".to_owned(), + "nested".to_owned(), + "not-generated-a".to_owned(), + ])); + assert!(!built_in_ignore("my-target")); + assert!(!built_in_ignore("builder")); + assert!(built_in_ignore("target")); + assert!(built_in_ignore("build")); + + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join(".git/hidden")).unwrap(); + let result = discover_package_manifests(&[temp.path().to_path_buf()], &policy).unwrap(); + assert_eq!(result.stats.built_in_pruned, 1); + assert_eq!(result.stats.configured_pruned, 0); + } + + #[cfg(unix)] + #[test] + fn unreadable_candidate_tree_fails_closed_when_permissions_are_enforced() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let unreadable = temp.path().join("unreadable"); + fs::create_dir(&unreadable).unwrap(); + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap(); + let probe = fs::read_dir(&unreadable); + if probe.is_ok() { + // Privileged CI users can bypass mode bits; the special-entry test + // still exercises the portable fail-closed classification path. + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o700)).unwrap(); + return; + } + let error = + discover_package_manifests(&[temp.path().to_path_buf()], &DiscoveryPolicy::default()) + .unwrap_err(); + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o700)).unwrap(); + let message = error.to_string(); + assert!(message.contains("failed closed"), "{message}"); + assert!(message.contains("read directory"), "{message}"); + assert!(message.contains("unreadable"), "{message}"); + } +} diff --git a/crates/brain-brew-cli/src/package_tree.rs b/crates/brain-brew-cli/src/package_tree.rs new file mode 100644 index 0000000..4b17327 --- /dev/null +++ b/crates/brain-brew-cli/src/package_tree.rs @@ -0,0 +1,906 @@ +//! One fail-closed policy for fetched package trees and archive extraction. +//! +//! Package snapshots and caches contain directories and regular files only. +//! Symlinks (including apparently contained links), hard links, and every +//! special filesystem/archive entry are rejected. + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use brain_brew_formats::safe_relative_path::SafeRelativePath; +use tar::{Archive, EntryType}; + +use crate::fetch_policy::{FetchPolicy, budget_error, check_total_deadline}; + +pub(crate) fn copy_filtered(source: &Path, destination: &Path) -> Result<(), String> { + let (canonical_source, source_metadata) = establish_root(source, "source package tree")?; + create_private_directory(destination)?; + if let Err(error) = copy_directory( + source, + &canonical_source, + &source_metadata, + destination, + true, + ) { + let _ = fs::remove_dir_all(destination); + return Err(error); + } + validate(destination, "staged package tree") +} + +pub(crate) fn copy_complete(source: &Path, destination: &Path) -> Result<(), String> { + validate(source, "staged package tree")?; + let (canonical_source, source_metadata) = establish_root(source, "staged package tree")?; + create_private_directory(destination)?; + if let Err(error) = copy_directory( + source, + &canonical_source, + &source_metadata, + destination, + false, + ) { + let _ = fs::remove_dir_all(destination); + return Err(error); + } + validate(destination, "publication package tree") +} + +pub(crate) fn validate(root: &Path, tree_name: &str) -> Result<(), String> { + let (canonical_root, root_metadata) = establish_root(root, tree_name)?; + validate_directory(root, &canonical_root, root, &root_metadata, tree_name) +} + +pub(crate) fn extract_tar( + tar_path: &Path, + destination: &Path, + policy: &FetchPolicy, + source: &str, + started: Instant, +) -> Result<(), String> { + preflight_raw_archive(tar_path, policy, source, started) + .map_err(|error| source_error(source, error))?; + + create_private_directory(destination).map_err(|error| source_error(source, error))?; + let result = extract_preflighted_archive(tar_path, destination, policy, source, started) + .and_then(|()| validate(destination, "extracted package tree")) + .map_err(|error| source_error(source, error)); + if result.is_err() { + let _ = fs::remove_dir_all(destination); + } + result +} + +fn source_error(source: &str, error: String) -> String { + if error.starts_with("package source ") { + error + } else { + format!("package source {source:?}: {error}") + } +} + +fn preflight_raw_archive( + tar_path: &Path, + policy: &FetchPolicy, + source: &str, + started: Instant, +) -> Result<(), String> { + let input = File::open(tar_path).map_err(|error| { + format!("package source {source:?}: failed to open staged tar: {error}") + })?; + let mut archive = Archive::new(input); + let entries = archive + .entries() + .map_err(|error| format!("failed to inspect tar archive: {error}"))? + .raw(true); + let mut entry_count = 0_u64; + let mut expanded_regular_bytes = 0_u64; + for (index, entry) in entries.enumerate() { + check_total_deadline(source, policy, started)?; + let mut entry = entry.map_err(|error| { + format!( + "package source {source:?}: failed to inspect raw tar entry {}: {error}", + index + 1 + ) + })?; + entry_count += 1; + if entry_count > policy.max_archive_entries { + return Err(budget_error( + source, + "archive_entry_count", + entry_count, + policy.max_archive_entries, + )); + } + let entry_type = entry.header().entry_type(); + let entry_size = entry.header().size().map_err(|error| { + format!( + "package source {source:?}: invalid size in tar entry {}: {error}", + index + 1 + ) + })?; + if entry_type == EntryType::Regular { + if entry_size > policy.max_regular_file_bytes { + return Err(budget_error( + source, + "regular_file_bytes", + entry_size, + policy.max_regular_file_bytes, + )); + } + expanded_regular_bytes = expanded_regular_bytes.saturating_add(entry_size); + if expanded_regular_bytes > policy.max_expanded_regular_bytes { + return Err(budget_error( + source, + "expanded_regular_bytes", + expanded_regular_bytes, + policy.max_expanded_regular_bytes, + )); + } + } else if matches!(entry_type, EntryType::GNULongName | EntryType::XHeader) + && entry_size > policy.max_archive_metadata_bytes + { + return Err(budget_error( + source, + "archive_metadata_bytes", + entry_size, + policy.max_archive_metadata_bytes, + )); + } + match entry_type { + EntryType::Regular | EntryType::Directory => { + validate_archive_path( + entry.header().path_bytes().as_ref(), + entry_type == EntryType::Directory, + index + 1, + policy, + source, + )?; + } + EntryType::GNULongName => { + let mut path = Vec::with_capacity(entry_size as usize); + entry + .take(policy.max_archive_metadata_bytes + 1) + .read_to_end(&mut path) + .map_err(|error| { + format!( + "package source {source:?}: failed to inspect GNU long-name tar entry {}: {error}", + index + 1 + ) + })?; + while matches!(path.last(), Some(0 | b'\n')) { + path.pop(); + } + validate_archive_path(&path, true, index + 1, policy, source)?; + } + EntryType::XHeader => { + validate_pax_path_metadata(&mut entry, index + 1, policy, source)? + } + EntryType::Link => return Err(rejected_archive_type(index + 1, "hard link")), + EntryType::Symlink => return Err(rejected_archive_type(index + 1, "symlink")), + EntryType::Char | EntryType::Block => { + return Err(rejected_archive_type(index + 1, "device")); + } + EntryType::Fifo => return Err(rejected_archive_type(index + 1, "fifo")), + EntryType::GNUSparse => return Err(rejected_archive_type(index + 1, "sparse")), + EntryType::GNULongLink => { + return Err(rejected_archive_type(index + 1, "long link")); + } + EntryType::Continuous | EntryType::XGlobalHeader | EntryType::__Nonexhaustive(_) => { + return Err(rejected_archive_type(index + 1, "unknown")); + } + } + } + Ok(()) +} + +fn validate_pax_path_metadata( + entry: &mut tar::Entry<'_, R>, + index: usize, + policy: &FetchPolicy, + source: &str, +) -> Result<(), String> { + let extensions = entry + .pax_extensions() + .map_err(|error| format!("failed to inspect PAX tar entry {index}: {error}"))? + .ok_or_else(|| { + format!("archive package tree policy rejected malformed PAX entry {index}") + })?; + for extension in extensions { + let extension = extension + .map_err(|error| format!("failed to inspect PAX tar entry {index}: {error}"))?; + match extension.key_bytes() { + b"path" => validate_archive_path(extension.value_bytes(), true, index, policy, source)?, + key if key.starts_with(b"GNU.sparse.") => { + return Err(rejected_archive_type(index, "sparse metadata")); + } + b"linkpath" => { + return Err(format!( + "archive package tree policy rejected link metadata in PAX entry {index}" + )); + } + _ => {} + } + } + Ok(()) +} + +fn extract_preflighted_archive( + tar_path: &Path, + destination: &Path, + policy: &FetchPolicy, + source: &str, + started: Instant, +) -> Result<(), String> { + let input = File::open(tar_path).map_err(|error| { + format!("package source {source:?}: failed to reopen staged tar: {error}") + })?; + let mut archive = Archive::new(input); + let entries = archive + .entries() + .map_err(|error| format!("failed to inspect tar archive: {error}"))?; + let mut targets = BTreeMap::::new(); + + for (index, entry) in entries.enumerate() { + check_total_deadline(source, policy, started)?; + let mut entry = + entry.map_err(|error| format!("failed to inspect tar entry {}: {error}", index + 1))?; + let entry_type = entry.header().entry_type(); + if !matches!(entry_type, EntryType::Regular | EntryType::Directory) { + return Err(rejected_archive_type( + index + 1, + archive_type_name(entry_type), + )); + } + let relative = validated_archive_relative( + entry.path_bytes().as_ref(), + entry_type == EntryType::Directory, + index + 1, + policy, + source, + )?; + reject_target_collision(&targets, relative.as_path(), entry_type, index + 1)?; + targets.insert(relative.clone(), entry_type); + + let target = destination.join(&relative); + ensure_archive_parents(destination, relative.as_path(), index + 1)?; + if entry_type == EntryType::Directory { + match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(format!( + "archive package tree policy rejected colliding directory target {:?} in entry {}", + relative.display(), + index + 1 + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(&target).map_err(|error| { + format!("failed to create {}: {error}", target.display()) + })?; + normalize_directory_permissions(&target)?; + } + Err(error) => return Err(format!("{}: {error}", target.display())), + } + } else { + let mut output = create_new_file(&target).map_err(|error| { + format!( + "archive package tree policy could not create-new/no-follow target {}: {error}", + target.display() + ) + })?; + let copied = copy_archive_file(&mut entry, &mut output, policy, source, started)?; + if copied > policy.max_regular_file_bytes { + return Err(budget_error( + source, + "regular_file_bytes", + copied, + policy.max_regular_file_bytes, + )); + } + output + .sync_all() + .map_err(|error| format!("failed to sync {}: {error}", target.display()))?; + normalize_file_permissions(&target, entry.header().mode().unwrap_or(0) & 0o111 != 0)?; + } + } + Ok(()) +} + +fn copy_archive_file( + input: &mut impl Read, + output: &mut impl io::Write, + policy: &FetchPolicy, + source: &str, + started: Instant, +) -> Result { + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + check_total_deadline(source, policy, started)?; + let count = input.read(&mut buffer).map_err(|error| { + format!("package source {source:?}: archive entry read failed: {error}") + })?; + if count == 0 { + break; + } + copied = copied.saturating_add(count as u64); + if copied > policy.max_regular_file_bytes { + return Err(budget_error( + source, + "regular_file_bytes", + copied, + policy.max_regular_file_bytes, + )); + } + output.write_all(&buffer[..count]).map_err(|error| { + format!("package source {source:?}: archive entry write failed: {error}") + })?; + } + Ok(copied) +} + +fn reject_target_collision( + targets: &BTreeMap, + relative: &Path, + entry_type: EntryType, + index: usize, +) -> Result<(), String> { + if targets.contains_key(relative) { + return Err(format!( + "archive package tree policy rejected duplicate normalized target {:?} in entry {index}", + relative.display() + )); + } + for ancestor in relative.ancestors().skip(1) { + if targets + .get(ancestor) + .is_some_and(|kind| *kind == EntryType::Regular) + { + return Err(format!( + "archive package tree policy rejected target {:?} below file {:?} in entry {index}", + relative.display(), + ancestor.display() + )); + } + } + if entry_type == EntryType::Regular && targets.keys().any(|target| target.starts_with(relative)) + { + return Err(format!( + "archive package tree policy rejected file target {:?} colliding with a child target in entry {index}", + relative.display() + )); + } + Ok(()) +} + +fn validate_archive_path( + raw: &[u8], + directory: bool, + index: usize, + policy: &FetchPolicy, + source: &str, +) -> Result<(), String> { + validated_archive_relative(raw, directory, index, policy, source).map(|_| ()) +} + +fn validated_archive_relative( + raw: &[u8], + directory: bool, + index: usize, + policy: &FetchPolicy, + source: &str, +) -> Result { + if raw.len() > policy.max_archive_path_bytes { + return Err(budget_error( + source, + "archive_path_bytes", + raw.len(), + policy.max_archive_path_bytes, + )); + } + let raw = std::str::from_utf8(raw).map_err(|_| { + format!("archive package tree policy rejected non-UTF-8 archive path in entry {index}") + })?; + let normalized = if directory { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + let safe = SafeRelativePath::new(normalized).map_err(|error| { + format!( + "archive package tree policy rejected archive path {raw:?} in entry {index}: {error}" + ) + })?; + let depth = safe.as_path().components().count(); + if depth > policy.max_archive_path_depth { + return Err(budget_error( + source, + "archive_path_depth", + depth, + policy.max_archive_path_depth, + )); + } + Ok(safe.as_path().to_path_buf()) +} + +fn rejected_archive_type(index: usize, kind: &str) -> String { + format!("archive package tree policy rejected {kind} entry {index}") +} + +fn archive_type_name(entry_type: EntryType) -> &'static str { + match entry_type { + EntryType::Link => "hard link", + EntryType::Symlink => "symlink", + EntryType::Char | EntryType::Block => "device", + EntryType::Fifo => "fifo", + EntryType::GNUSparse => "sparse", + _ => "unknown", + } +} + +fn ensure_archive_parents(root: &Path, relative: &Path, index: usize) -> Result<(), String> { + let parent = relative + .parent() + .ok_or_else(|| format!("archive entry {index} has no authorized parent"))?; + let mut current = root.to_path_buf(); + for component in parent.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(format!( + "archive package tree policy rejected replaced/non-directory parent {} in entry {index}", + current.display() + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(¤t) + .map_err(|error| format!("failed to create {}: {error}", current.display()))?; + normalize_directory_permissions(¤t)?; + } + Err(error) => return Err(format!("{}: {error}", current.display())), + } + } + Ok(()) +} + +fn establish_root(root: &Path, tree_name: &str) -> Result<(PathBuf, fs::Metadata), String> { + let before = fs::symlink_metadata(root) + .map_err(|error| format!("{tree_name} {}: {error}", root.display()))?; + let kind = classify(&before); + if kind != "directory" { + return Err(format!( + "{tree_name} policy rejected root {}: expected directory, found {kind}", + root.display() + )); + } + let canonical = fs::canonicalize(root) + .map_err(|error| format!("{tree_name} root {}: {error}", root.display()))?; + let after = fs::symlink_metadata(root) + .map_err(|error| format!("{tree_name} root {} was replaced: {error}", root.display()))?; + if !after.is_dir() || !same_file(&before, &after) { + return Err(format!( + "{tree_name} policy rejected root {}: directory was replaced while authorizing it", + root.display() + )); + } + Ok((canonical, after)) +} + +fn validate_directory( + root: &Path, + canonical_root: &Path, + directory: &Path, + expected: &fs::Metadata, + tree_name: &str, +) -> Result<(), String> { + authorize_existing_directory(directory, canonical_root, expected, tree_name)?; + let mut entries = fs::read_dir(directory) + .map_err(|error| format!("{}: {error}", directory.display()))? + .collect::, _>>() + .map_err(|error| error.to_string())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = + fs::symlink_metadata(&path).map_err(|error| format!("{}: {error}", path.display()))?; + let kind = classify(&metadata); + if kind == "directory" { + validate_directory(root, canonical_root, &path, &metadata, tree_name)?; + } else if kind == "regular file" { + reject_hard_link(&path, &metadata, tree_name)?; + authorize_existing_file(&path, canonical_root, &metadata, tree_name)?; + } else { + return Err(tree_rejection(root, &path, tree_name, kind)); + } + } + Ok(()) +} + +fn authorize_existing_directory( + directory: &Path, + canonical_root: &Path, + expected: &fs::Metadata, + tree_name: &str, +) -> Result<(), String> { + let before = fs::symlink_metadata(directory) + .map_err(|error| format!("{}: {error}", directory.display()))?; + if !before.is_dir() || !same_file(expected, &before) { + return Err(format!( + "{tree_name} policy rejected {}: directory was replaced during traversal", + directory.display() + )); + } + let canonical = + fs::canonicalize(directory).map_err(|error| format!("{}: {error}", directory.display()))?; + if !canonical.starts_with(canonical_root) { + return Err(format!( + "{tree_name} policy rejected {}: replaced directory resolves outside authorized root {}", + directory.display(), + canonical_root.display() + )); + } + let after = fs::symlink_metadata(directory) + .map_err(|error| format!("{}: {error}", directory.display()))?; + if !after.is_dir() || !same_file(expected, &after) { + return Err(format!( + "{tree_name} policy rejected {}: directory was replaced while authorizing traversal", + directory.display() + )); + } + Ok(()) +} + +fn authorize_existing_file( + path: &Path, + canonical_root: &Path, + expected: &fs::Metadata, + tree_name: &str, +) -> Result<(), String> { + let canonical = + fs::canonicalize(path).map_err(|error| format!("{}: {error}", path.display()))?; + if !canonical.starts_with(canonical_root) { + return Err(format!( + "{tree_name} policy rejected {}: replaced file resolves outside authorized root {}", + path.display(), + canonical_root.display() + )); + } + let after = + fs::symlink_metadata(path).map_err(|error| format!("{}: {error}", path.display()))?; + if !after.is_file() || !same_file(expected, &after) { + return Err(format!( + "{tree_name} policy rejected {}: file was replaced while authorizing it", + path.display() + )); + } + Ok(()) +} + +fn copy_directory( + source: &Path, + canonical_root: &Path, + expected: &fs::Metadata, + destination: &Path, + filtered: bool, +) -> Result<(), String> { + authorize_existing_directory(source, canonical_root, expected, "source package tree")?; + let mut entries = fs::read_dir(source) + .map_err(|error| format!("{}: {error}", source.display()))? + .collect::, _>>() + .map_err(|error| error.to_string())?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let name = entry.file_name(); + if filtered && should_skip(&name.to_string_lossy()) { + continue; + } + let source_path = entry.path(); + let destination_path = destination.join(&name); + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| format!("{}: {error}", source_path.display()))?; + let kind = classify(&metadata); + if kind == "directory" { + fs::create_dir(&destination_path) + .map_err(|error| format!("{}: {error}", destination_path.display()))?; + normalize_directory_permissions(&destination_path)?; + copy_directory( + &source_path, + canonical_root, + &metadata, + &destination_path, + filtered, + )?; + } else if kind == "regular file" { + reject_hard_link(&source_path, &metadata, "source package tree")?; + authorize_existing_file( + &source_path, + canonical_root, + &metadata, + "source package tree", + )?; + copy_regular_file(&source_path, &destination_path, &metadata)?; + } else { + return Err(format!( + "source package tree policy rejected {} ({kind})", + source_path.display() + )); + } + } + Ok(()) +} + +fn copy_regular_file( + source: &Path, + destination: &Path, + before: &fs::Metadata, +) -> Result<(), String> { + let mut input = + open_file_no_follow(source).map_err(|error| format!("{}: {error}", source.display()))?; + let opened = input + .metadata() + .map_err(|error| format!("{}: {error}", source.display()))?; + if !opened.is_file() || !same_file(before, &opened) { + return Err(format!( + "source package tree policy rejected {}: file was replaced while snapshotting", + source.display() + )); + } + let mut output = create_new_file(destination) + .map_err(|error| format!("{}: {error}", destination.display()))?; + io::copy(&mut input, &mut output).map_err(|error| { + format!( + "failed to copy {} to {}: {error}", + source.display(), + destination.display() + ) + })?; + output + .sync_all() + .map_err(|error| format!("{}: {error}", destination.display()))?; + normalize_file_permissions(destination, executable(before)) +} + +fn open_file_no_follow(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + options.open(path) +} + +fn create_new_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + options.open(path) +} + +#[cfg(unix)] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() && left.file_type() == right.file_type() +} + +#[cfg(not(unix))] +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.file_type() == right.file_type() + && left.len() == right.len() + && left.modified().ok() == right.modified().ok() +} + +#[cfg(unix)] +fn reject_hard_link(path: &Path, metadata: &fs::Metadata, tree_name: &str) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(format!( + "{tree_name} policy rejected {} (hard link count {})", + path.display(), + metadata.nlink() + )); + } + Ok(()) +} + +#[cfg(not(unix))] +fn reject_hard_link( + _path: &Path, + _metadata: &fs::Metadata, + _tree_name: &str, +) -> Result<(), String> { + Ok(()) +} + +fn tree_rejection(root: &Path, path: &Path, tree_name: &str, kind: &str) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + format!( + "{tree_name} policy rejected {} ({kind}); packages may contain only regular files and directories, and symlinks require lock regeneration under the reject-all-symlinks policy", + relative.display() + ) +} + +fn classify(metadata: &fs::Metadata) -> &'static str { + let file_type = metadata.file_type(); + if file_type.is_symlink() { + "symlink" + } else if file_type.is_dir() { + "directory" + } else if file_type.is_file() { + "regular file" + } else { + classify_special(&file_type) + } +} + +#[cfg(unix)] +fn classify_special(file_type: &fs::FileType) -> &'static str { + use std::os::unix::fs::FileTypeExt; + if file_type.is_socket() { + "socket" + } else if file_type.is_fifo() { + "fifo" + } else if file_type.is_char_device() || file_type.is_block_device() { + "device" + } else { + "special file" + } +} + +#[cfg(not(unix))] +fn classify_special(_file_type: &fs::FileType) -> &'static str { + "special file" +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir(path).map_err(|error| format!("{}: {error}", path.display()))?; + normalize_directory_permissions(path) +} + +#[cfg(unix)] +fn executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn executable(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn normalize_directory_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("{}: {error}", path.display())) +} + +#[cfg(not(unix))] +fn normalize_directory_permissions(path: &Path) -> Result<(), String> { + let mut permissions = fs::metadata(path) + .map_err(|error| format!("{}: {error}", path.display()))? + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).map_err(|error| format!("{}: {error}", path.display())) +} + +#[cfg(unix)] +fn normalize_file_permissions(path: &Path, executable: bool) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + let mode = if executable { 0o755 } else { 0o644 }; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|error| format!("{}: {error}", path.display())) +} + +#[cfg(not(unix))] +fn normalize_file_permissions(path: &Path, _executable: bool) -> Result<(), String> { + let mut permissions = fs::metadata(path) + .map_err(|error| format!("{}: {error}", path.display()))? + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions).map_err(|error| format!("{}: {error}", path.display())) +} + +pub(crate) fn should_skip(name: &str) -> bool { + matches!(name, ".git" | ".jj" | ".hg" | ".svn" | "target" | "result") + || name.starts_with("result-") +} + +#[cfg(test)] +mod tests { + use super::*; + use tar::{Builder, Header}; + + #[test] + fn archive_preflight_enforces_entry_file_total_and_path_budgets() { + assert_budget( + &[("one", b"12345".as_slice()), ("two", b"x".as_slice())], + FetchPolicy { + max_regular_file_bytes: 4, + ..FetchPolicy::default() + }, + "regular_file_bytes", + ); + assert_budget( + &[("one", b"1"), ("two", b"2")], + FetchPolicy { + max_archive_entries: 1, + ..FetchPolicy::default() + }, + "archive_entry_count", + ); + assert_budget( + &[("one", b"123"), ("two", b"456")], + FetchPolicy { + max_expanded_regular_bytes: 5, + ..FetchPolicy::default() + }, + "expanded_regular_bytes", + ); + assert_budget( + &[("one/two/three", b"x")], + FetchPolicy { + max_archive_path_depth: 2, + ..FetchPolicy::default() + }, + "archive_path_depth", + ); + assert_budget( + &[("long-name", b"x")], + FetchPolicy { + max_archive_path_bytes: 4, + ..FetchPolicy::default() + }, + "archive_path_bytes", + ); + assert_budget( + &[("one", b"x")], + FetchPolicy { + total_timeout: std::time::Duration::ZERO, + ..FetchPolicy::default() + }, + "total_timeout", + ); + } + + fn assert_budget(entries: &[(&str, &[u8])], policy: FetchPolicy, expected: &str) { + let root = tempfile::tempdir().unwrap(); + let tar_path = root.path().join("fixture.tar"); + let output = File::create(&tar_path).unwrap(); + let mut builder = Builder::new(output); + for (path, bytes) in entries { + let mut header = Header::new_gnu(); + header.set_path(path).unwrap(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append(&header, *bytes).unwrap(); + } + builder.finish().unwrap(); + let destination = root.path().join("out"); + let error = extract_tar( + &tar_path, + &destination, + &policy, + "fixture://archive", + Instant::now(), + ) + .expect_err("archive exceeds its injected budget"); + assert!(error.contains(expected), "{error}"); + assert!(error.contains("current="), "{error}"); + assert!(error.contains("limit="), "{error}"); + assert!( + !destination.exists(), + "failed extraction must clean staging" + ); + } +} diff --git a/crates/brain-brew-cli/src/path_authorization.rs b/crates/brain-brew-cli/src/path_authorization.rs new file mode 100644 index 0000000..3d4575f --- /dev/null +++ b/crates/brain-brew-cli/src/path_authorization.rs @@ -0,0 +1,315 @@ +//! Canonical-root filesystem authorization for schema-owned relative paths. + +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::safe_relative_path::SafeRelativePath; + +/// A selected, canonical filesystem root. Selecting the root is a caller policy +/// decision; paths authorized beneath it can never select another root. +#[derive(Clone, Debug)] +pub(crate) struct PathAuthorizer { + root_name: String, + canonical_root: PathBuf, +} + +impl PathAuthorizer { + pub(crate) fn new(root_name: impl Into, root: &Path) -> Result { + let root_name = root_name.into(); + let canonical_root = fs::canonicalize(root) + .map_err(|error| format!("selected {root_name} root {}: {error}", root.display()))?; + let metadata = fs::metadata(&canonical_root).map_err(|error| { + format!( + "selected {root_name} root {}: {error}", + canonical_root.display() + ) + })?; + if !metadata.is_dir() { + return Err(format!( + "selected {root_name} root {} is not a directory", + canonical_root.display() + )); + } + Ok(Self { + root_name, + canonical_root, + }) + } + + /// Authorize an existing file or directory after resolving all symlinks. + pub(crate) fn authorize_read( + &self, + declaring_file: &Path, + field: impl Into, + raw: &str, + ) -> Result> { + let request = self.request(declaring_file, field, raw)?; + let joined = self.canonical_root.join(request.relative.as_path()); + let resolved = fs::canonicalize(&joined).map_err(|error| { + request.error(format!("target cannot be resolved for reading: {error}")) + })?; + self.require_contained(&request, &resolved)?; + Ok(AuthorizedPath { resolved }) + } + + /// Authorize an existing or new target by resolving its deepest existing + /// ancestor. Missing suffixes are appended only after the ancestor is known + /// to be a contained directory. + pub(crate) fn authorize_create( + &self, + declaring_file: &Path, + field: impl Into, + raw: &str, + ) -> Result> { + let request = self.request(declaring_file, field, raw)?; + let joined = self.canonical_root.join(request.relative.as_path()); + match fs::symlink_metadata(&joined) { + Ok(_) => { + let resolved = fs::canonicalize(&joined).map_err(|error| { + request.error(format!("existing target cannot be resolved: {error}")) + })?; + self.require_contained(&request, &resolved)?; + return Ok(AuthorizedPath { resolved }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(request.error(format!("target cannot be inspected: {error}"))); + } + } + + let mut ancestor = joined.as_path(); + let mut suffix = Vec::new(); + loop { + match fs::symlink_metadata(ancestor) { + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let name = ancestor.file_name().ok_or_else(|| { + request.error("target has no existing ancestor".to_owned()) + })?; + suffix.push(name.to_os_string()); + ancestor = ancestor.parent().ok_or_else(|| { + request.error("target has no existing ancestor".to_owned()) + })?; + } + Err(error) => { + return Err(request.error(format!( + "target ancestor {} cannot be inspected: {error}", + ancestor.display() + ))); + } + } + } + let resolved_ancestor = fs::canonicalize(ancestor).map_err(|error| { + request.error(format!( + "target ancestor {} cannot be resolved: {error}", + ancestor.display() + )) + })?; + self.require_contained(&request, &resolved_ancestor)?; + if !fs::metadata(&resolved_ancestor) + .map_err(|error| { + request.error(format!("target ancestor cannot be inspected: {error}")) + })? + .is_dir() + { + return Err(request.error(format!( + "deepest existing ancestor {} is not a directory", + resolved_ancestor.display() + ))); + } + let mut resolved = resolved_ancestor; + for component in suffix.iter().rev() { + resolved.push(component); + } + Ok(AuthorizedPath { resolved }) + } + + fn request( + &self, + declaring_file: &Path, + field: impl Into, + raw: &str, + ) -> Result> { + let field = field.into(); + let relative = SafeRelativePath::new(raw).map_err(|reason| { + Box::new(PathAuthorizationError { + declaring_file: declaring_file.to_path_buf(), + field: field.clone(), + raw: raw.to_owned(), + root_name: self.root_name.clone(), + root: self.canonical_root.clone(), + reason: reason.to_string(), + }) + })?; + Ok(PathRequest { + declaring_file: declaring_file.to_path_buf(), + field, + raw: raw.to_owned(), + root_name: self.root_name.clone(), + root: self.canonical_root.clone(), + relative, + }) + } + + fn require_contained( + &self, + request: &PathRequest, + resolved: &Path, + ) -> Result<(), Box> { + if resolved.starts_with(&self.canonical_root) { + Ok(()) + } else { + Err(request.error(format!( + "resolved path {} escapes the selected root", + resolved.display() + ))) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AuthorizedPath { + resolved: PathBuf, +} + +impl AuthorizedPath { + #[cfg(test)] + pub(crate) fn as_path(&self) -> &Path { + &self.resolved + } + + pub(crate) fn into_path_buf(self) -> PathBuf { + self.resolved + } +} + +struct PathRequest { + declaring_file: PathBuf, + field: String, + raw: String, + root_name: String, + root: PathBuf, + relative: SafeRelativePath, +} + +impl PathRequest { + fn error(&self, reason: String) -> Box { + Box::new(PathAuthorizationError { + declaring_file: self.declaring_file.clone(), + field: self.field.clone(), + raw: self.raw.clone(), + root_name: self.root_name.clone(), + root: self.root.clone(), + reason, + }) + } +} + +/// Typed diagnostic for a denied filesystem path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PathAuthorizationError { + pub(crate) declaring_file: PathBuf, + pub(crate) field: String, + pub(crate) raw: String, + pub(crate) root_name: String, + pub(crate) root: PathBuf, + pub(crate) reason: String, +} + +impl fmt::Display for PathAuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{}:{} path {:?} is not authorized under {} root {}: {}", + self.declaring_file.display(), + self.field, + self.raw, + self.root_name, + self.root.display(), + self.reason + ) + } +} + +impl std::error::Error for PathAuthorizationError {} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::PathAuthorizer; + + #[test] + fn diagnostic_names_declaration_field_value_root_and_reason() { + let root = TempDir::new().unwrap(); + let authorizer = PathAuthorizer::new("package", root.path()).unwrap(); + let declaring = root.path().join("brainbrew.yaml"); + let error = authorizer + .authorize_read(&declaring, "base", "../outside.yaml") + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains(&declaring.display().to_string())); + assert!(message.contains(":base")); + assert!(message.contains("\"../outside.yaml\"")); + assert!(message.contains("package root")); + assert!(message.contains("parent-directory")); + } + + #[test] + fn authorizes_existing_and_new_contained_targets() { + let root = TempDir::new().unwrap(); + fs::create_dir_all(root.path().join("nested")).unwrap(); + fs::write(root.path().join("nested/deck.yaml"), "deck").unwrap(); + let authorizer = PathAuthorizer::new("workspace", root.path()).unwrap(); + assert_eq!( + authorizer + .authorize_read(Path::new("brainbrew.yaml"), "base", "nested/deck.yaml") + .unwrap() + .as_path(), + fs::canonicalize(root.path().join("nested/deck.yaml")).unwrap() + ); + assert_eq!( + authorizer + .authorize_create( + Path::new("brainbrew.yaml"), + "targets.x.exports.crowdanki.out", + "nested/new/deck.json", + ) + .unwrap() + .as_path(), + fs::canonicalize(root.path().join("nested")) + .unwrap() + .join("new/deck.json") + ); + } + + #[cfg(unix)] + #[test] + fn rejects_existing_and_nonexistent_targets_through_escaping_symlinks() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + fs::write(outside.path().join("secret.yaml"), "secret").unwrap(); + symlink(outside.path(), root.path().join("escape")).unwrap(); + let authorizer = PathAuthorizer::new("package", root.path()).unwrap(); + + for result in [ + authorizer.authorize_read(Path::new("brainbrew.yaml"), "base", "escape/secret.yaml"), + authorizer.authorize_create( + Path::new("brainbrew.yaml"), + "overlays.x.file", + "escape/new/overlay.yaml", + ), + ] { + let error = result.unwrap_err().to_string(); + assert!(error.contains("escapes the selected root"), "{error}"); + } + } + + use std::path::Path; +} diff --git a/crates/brain-brew-cli/src/planner.rs b/crates/brain-brew-cli/src/planner.rs new file mode 100644 index 0000000..64bf209 --- /dev/null +++ b/crates/brain-brew-cli/src/planner.rs @@ -0,0 +1,1200 @@ +//! Registry-aware planning for every manifest-backed CLI operation. +//! +//! This is the only filesystem package/target expansion engine used by command +//! routes. `brain-brew-formats` retains its pure single-manifest expansion API +//! for codec consumers, but the CLI never uses it to select or expand targets. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_core::{CanonicalDeck, ChangeIntent, FieldValue, Overlay, OverlayKind}; +use brain_brew_formats::manifest::{self, FederatedDeckManifest}; +use brain_brew_formats::media; +use brain_brew_formats::source_document::{ + IncludedSourceKind, SourceProvenance as DocumentProvenance, +}; +use sha2::{Digest, Sha256}; + +use crate::commands::lock::locked_package_manifest_paths; +use crate::io::{ + canonical_document_from_package, include_roots_from_manifest, manifest_root, + overlay_document_from_package, read_manifest, +}; +use crate::package_resolver::{ + DiscoveryPolicy, DiscoveryResult, DiscoveryStats, discover_package_manifests, + validate_package_dependencies, +}; +use crate::path_authorization::PathAuthorizer; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) struct PackageIdentity { + pub(crate) id: String, + pub(crate) version: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum RegistrySourceKind { + RootManifest, + ExplicitInclude, + PackageRoot, + SiblingLock, +} + +impl RegistrySourceKind { + pub(crate) fn ownership_name(self) -> &'static str { + match self { + Self::RootManifest => "root", + Self::ExplicitInclude => "include", + Self::PackageRoot => "path", + Self::SiblingLock => "locked", + } + } + + pub(crate) fn is_root_workspace(self) -> bool { + self == Self::RootManifest + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PlanSourceKind { + Base, + Overlay { kind: OverlayKind }, + ScalarInclude { schema_path: String }, + MediaInclude, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SourceProvenance { + pub(crate) package: Option, + pub(crate) package_root: PathBuf, + pub(crate) manifest: PathBuf, + pub(crate) registry_source: RegistrySourceKind, + pub(crate) path: PathBuf, + pub(crate) kind: PlanSourceKind, + pub(crate) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TargetExpansionOrigin { + Selection, + Extends { + declaring_target: String, + reference: String, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TargetExpansion { + pub(crate) qualified_name: String, + pub(crate) owner: Option, + pub(crate) origin: TargetExpansionOrigin, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum OverlayExpansionOrigin { + Target { + qualified_target: String, + reference: String, + }, + Dependency { + declaring_overlay: String, + reference: String, + }, +} + +#[derive(Clone, Debug)] +pub(crate) struct PlannedOverlay { + pub(crate) id: String, + pub(crate) qualified_id: String, + pub(crate) file: PathBuf, + pub(crate) display_file: String, + pub(crate) package: Option, + pub(crate) kind: OverlayKind, + pub(crate) declared_kind: Option, + pub(crate) source: SourceProvenance, + pub(crate) includes: Vec, + pub(crate) origin: OverlayExpansionOrigin, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MediaDeclarationProvenance { + pub(crate) id: String, + pub(crate) path: String, + pub(crate) sha256: String, + pub(crate) package: Option, + pub(crate) package_root: PathBuf, + pub(crate) source_kind: RegistrySourceKind, + /// File that contains the declaration (for example an included media map). + pub(crate) source: PathBuf, + /// Canonical Deck or Overlay document through which typed mutation occurs. + pub(crate) document_source: PathBuf, +} + +impl MediaDeclarationProvenance { + pub(crate) fn package_label(&self) -> &str { + self.package + .as_ref() + .map(|package| package.id.as_str()) + .unwrap_or("") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MediaReferenceBinding { + /// Stable-ID or rendered-path reference selected by the composed deck. + pub(crate) reference: String, + pub(crate) declaration: MediaDeclarationProvenance, +} + +pub(crate) struct TargetPlan { + pub(crate) package: Option, + pub(crate) owner: Option, + pub(crate) target: String, + pub(crate) qualified_name: String, + pub(crate) target_manifest_root: PathBuf, + pub(crate) target_manifest: FederatedDeckManifest, + pub(crate) base_label: String, + pub(crate) base: CanonicalDeck, + pub(crate) base_source: SourceProvenance, + pub(crate) base_includes: Vec, + pub(crate) overlays: Vec<(PlannedOverlay, Overlay)>, + pub(crate) target_expansion: Vec, + pub(crate) media_declarations: BTreeMap, +} + +impl TargetPlan { + pub(crate) fn compose(&self) -> Result { + self.base.compose( + &self + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + } + + pub(crate) fn media_reference_bindings( + &self, + deck: &CanonicalDeck, + ) -> Result, String> { + bind_media_references(&self.qualified_name, deck, &self.media_declarations) + } + + pub(crate) fn sources(&self) -> impl Iterator { + std::iter::once(&self.base_source) + .chain(self.base_includes.iter()) + .chain(self.overlays.iter().flat_map(|(planned, _)| { + std::iter::once(&planned.source).chain(planned.includes.iter()) + })) + } +} + +#[derive(Clone)] +pub(crate) struct RegistryManifest { + pub(crate) path: PathBuf, + pub(crate) root: PathBuf, + pub(crate) include_roots: Vec, + pub(crate) identity: Option, + pub(crate) discovery: RegistrySourceKind, + pub(crate) manifest: FederatedDeckManifest, +} + +pub(crate) fn plan_manifest_target( + manifest_path: &Path, + target: &str, + include_paths: &[PathBuf], + package_roots: &[PathBuf], + discovery_policy: &DiscoveryPolicy, +) -> Result { + ManifestRegistry::load_with_policy( + manifest_path, + include_paths, + package_roots, + discovery_policy, + )? + .plan(target) +} + +pub(crate) struct ManifestRegistry { + root_index: usize, + manifests: Vec, + packages: BTreeMap, + discovery_stats: DiscoveryStats, +} + +impl ManifestRegistry { + pub(crate) fn load( + manifest_path: &Path, + include_paths: &[PathBuf], + package_roots: &[PathBuf], + ) -> Result { + Self::load_with_policy( + manifest_path, + include_paths, + package_roots, + &DiscoveryPolicy::default(), + ) + } + + pub(crate) fn load_with_policy( + manifest_path: &Path, + include_paths: &[PathBuf], + package_roots: &[PathBuf], + discovery_policy: &DiscoveryPolicy, + ) -> Result { + let discovery = discover_package_manifests(package_roots, discovery_policy) + .map_err(|error| error.to_string())?; + Self::load_with_discovery(manifest_path, include_paths, discovery) + } + + pub(crate) fn discover_manifest_paths( + package_roots: &[PathBuf], + discovery_policy: &DiscoveryPolicy, + ) -> Result, String> { + discover_package_manifests(package_roots, discovery_policy) + .map(|result| result.manifests) + .map_err(|error| error.to_string()) + } + + pub(crate) fn discover( + package_roots: &[PathBuf], + discovery_policy: &DiscoveryPolicy, + ) -> Result { + let discovery = discover_package_manifests(package_roots, discovery_policy) + .map_err(|error| error.to_string())?; + let Some(root) = discovery.manifests.first().cloned() else { + return Err("no Brain Brew package manifests were discovered".to_owned()); + }; + Self::load_with_discovery(&root, &[], discovery) + } + + fn load_with_discovery( + manifest_path: &Path, + include_paths: &[PathBuf], + discovery: DiscoveryResult, + ) -> Result { + let root_path = canonical_manifest_path(manifest_path)?; + let mut candidates = BTreeMap::::new(); + insert_candidate( + &mut candidates, + root_path.clone(), + RegistrySourceKind::RootManifest, + ); + for path in include_paths { + insert_candidate( + &mut candidates, + canonical_manifest_path(path)?, + RegistrySourceKind::ExplicitInclude, + ); + } + for path in &discovery.manifests { + insert_candidate( + &mut candidates, + canonical_manifest_path(path)?, + RegistrySourceKind::PackageRoot, + ); + } + + // Locks are siblings of selected package manifests. Iterate to a fixed + // point so a package discovered through an include/root has exactly the + // same lock behavior as one found through another route. + let mut scanned_locks = BTreeSet::new(); + loop { + let selected = candidates.keys().cloned().collect::>(); + let mut changed = false; + for path in selected { + let lock_path = manifest_root(&path).join("brainbrew.lock"); + if !scanned_locks.insert(lock_path.clone()) { + continue; + } + for locked in locked_package_manifest_paths(&lock_path)? { + let locked = canonical_manifest_path(&locked)?; + changed |= + insert_candidate(&mut candidates, locked, RegistrySourceKind::SiblingLock); + } + } + if !changed { + break; + } + } + + let mut manifests = Vec::new(); + for (path, discovery) in candidates { + let manifest = read_manifest(&path)?; + let root = manifest_root(&path); + let include_roots = include_roots_from_manifest(&path, &root, &manifest)?; + let identity = manifest.package.as_ref().map(|package| PackageIdentity { + id: package.id.clone(), + version: package.version.clone(), + }); + manifests.push(RegistryManifest { + path, + root, + include_roots, + identity, + discovery, + manifest, + }); + } + manifests.sort_by(|left, right| { + source_precedence(left.discovery) + .cmp(&source_precedence(right.discovery)) + .then_with(|| left.path.cmp(&right.path)) + }); + let root_index = manifests + .iter() + .position(|loaded| loaded.path == root_path) + .ok_or_else(|| "root manifest disappeared while building registry".to_owned())?; + + validate_package_dependencies( + &manifests + .iter() + .map(|loaded| (&loaded.path, &loaded.manifest)) + .collect::>(), + )?; + validate_overlay_catalogs(&manifests)?; + + let mut packages = BTreeMap::new(); + for (index, loaded) in manifests.iter().enumerate() { + let Some(identity) = &loaded.identity else { + continue; + }; + if let Some(previous) = packages.insert(identity.id.clone(), index) { + let previous = &manifests[previous]; + let conflict = if previous.identity.as_ref() == Some(identity) { + "duplicate package identity" + } else { + "conflicting package identity" + }; + return Err(format!( + "{conflict} {}@{} in {} and {}", + identity.id, + identity.version, + previous.path.display(), + loaded.path.display() + )); + } + } + + Ok(Self { + root_index, + manifests, + packages, + discovery_stats: discovery.stats, + }) + } + + pub(crate) fn manifests(&self) -> &[RegistryManifest] { + &self.manifests + } + + pub(crate) fn discovery_stats(&self) -> &DiscoveryStats { + &self.discovery_stats + } + + pub(crate) fn root(&self) -> &RegistryManifest { + &self.manifests[self.root_index] + } + + /// Load every registered base/overlay once and return its complete include + /// dependency set. Workbench freshness uses this instead of re-planning all + /// targets, so signatures retain provenance without target-count work. + pub(crate) fn registered_sources(&self) -> Result, String> { + let mut sources = BTreeMap::::new(); + for loaded in &self.manifests { + let base_path = authorize_manifest_path(loaded, "base", &loaded.manifest.base)?; + let base_document = + canonical_document_from_package(&base_path, &loaded.root, &loaded.include_roots)?; + let source = source_provenance(loaded, base_path, PlanSourceKind::Base)?; + sources.insert(source.path.clone(), source); + for include in included_provenance(loaded, base_document.included_sources())? { + sources.insert(include.path.clone(), include); + } + for (overlay_id, entry) in &loaded.manifest.overlays { + let file = authorize_manifest_path( + loaded, + format!("overlays.{overlay_id}.file"), + &entry.file, + )?; + let document = + overlay_document_from_package(&file, &loaded.root, &loaded.include_roots)?; + let overlay = document.resolved_overlay(); + let kind = overlay.kind; + let source = source_provenance(loaded, file, PlanSourceKind::Overlay { kind })?; + sources.insert(source.path.clone(), source); + for include in included_provenance(loaded, document.included_sources())? { + sources.insert(include.path.clone(), include); + } + } + } + Ok(sources.into_values().collect()) + } + + pub(crate) fn target_references(&self) -> Vec { + let qualify = self.manifests.len() > 1; + self.manifests + .iter() + .flat_map(|loaded| { + loaded.manifest.targets.keys().map(move |target| { + if qualify { + loaded + .identity + .as_ref() + .map(|identity| format!("{}:{target}", identity.id)) + .unwrap_or_else(|| target.clone()) + } else { + target.clone() + } + }) + }) + .collect() + } + + pub(crate) fn plan(&self, target_reference: &str) -> Result { + let (manifest_index, target) = self.resolve_selected_target(target_reference)?; + let selected_qualified = self.qualified_target(manifest_index, target); + let mut blueprint = Blueprint::default(); + self.visit_target( + manifest_index, + target, + TargetExpansionOrigin::Selection, + &mut Vec::new(), + &mut blueprint, + )?; + let base_manifest_index = blueprint + .base_manifest + .ok_or_else(|| format!("target {selected_qualified} did not select a base source"))?; + let base_loaded = &self.manifests[base_manifest_index]; + let base_path = authorize_manifest_path(base_loaded, "base", &base_loaded.manifest.base)?; + let base_document = canonical_document_from_package( + &base_path, + &base_loaded.root, + &base_loaded.include_roots, + )?; + let base = base_document.resolved_deck().clone(); + let base_source = source_provenance(base_loaded, base_path, PlanSourceKind::Base)?; + let base_includes = included_provenance(base_loaded, base_document.included_sources())?; + + let mut overlays = Vec::new(); + let media_declaration_source = base_includes + .iter() + .find(|source| source.kind == PlanSourceKind::MediaInclude) + .map(|source| source.path.as_path()) + .unwrap_or(base_source.path.as_path()); + let mut media_declarations = base + .media + .values() + .map(|media| { + ( + media.id.to_string(), + media_owner( + base_loaded, + media_declaration_source, + &base_source.path, + media, + ), + ) + }) + .collect::>(); + for overlay_blueprint in blueprint.overlays { + let loaded = &self.manifests[overlay_blueprint.manifest_index]; + let entry = &loaded.manifest.overlays[&overlay_blueprint.overlay_id]; + let file = authorize_manifest_path( + loaded, + format!("overlays.{}.file", overlay_blueprint.overlay_id), + &entry.file, + )?; + let document = + overlay_document_from_package(&file, &loaded.root, &loaded.include_roots)?; + let overlay = document.resolved_overlay().clone(); + let kind = overlay.kind; + let qualified_id = self.qualified_overlay( + overlay_blueprint.manifest_index, + &overlay_blueprint.overlay_id, + ); + let source = source_provenance(loaded, file.clone(), PlanSourceKind::Overlay { kind })?; + let includes = included_provenance(loaded, document.included_sources())?; + for (id, change) in &overlay.media_changes { + match change.intent { + ChangeIntent::Remove => { + media_declarations.remove(id.as_str()); + } + _ => { + if let Some(media) = &change.media { + if let Some(previous) = media_declarations.get(id.as_str()) + && previous.package_root != loaded.root + && change.intent == ChangeIntent::Merge + { + return Err(format!( + "cross-package media stable-ID collision for target {selected_qualified}: declaration {id} is owned by package {} at {}, but package {} attempts an ambiguous merge from {}; use an explicit replace/override with expected_base to transfer ownership", + previous.package_label(), + previous.source.display(), + loaded + .identity + .as_ref() + .map(|package| package.id.as_str()) + .unwrap_or(""), + source.path.display() + )); + } + media_declarations.insert( + id.to_string(), + media_owner(loaded, &source.path, &source.path, media), + ); + } + } + } + } + let display_file = if overlay_blueprint.manifest_index == self.root_index { + entry.file.clone() + } else if let Some(identity) = &loaded.identity { + format!("{}:{}", identity.id, entry.file) + } else { + entry.file.clone() + }; + overlays.push(( + PlannedOverlay { + id: if overlay_blueprint.manifest_index == self.root_index { + overlay_blueprint.overlay_id.clone() + } else { + qualified_id.clone() + }, + qualified_id, + file, + display_file, + package: loaded.identity.clone(), + kind, + declared_kind: entry.kind.clone(), + source, + includes, + origin: overlay_blueprint.origin, + }, + overlay, + )); + } + + validate_media_declaration_collisions(&selected_qualified, &media_declarations)?; + + let selected = &self.manifests[manifest_index]; + Ok(TargetPlan { + package: selected.manifest.package.clone(), + owner: selected.identity.clone(), + target: target.to_owned(), + qualified_name: selected_qualified, + target_manifest_root: selected.root.clone(), + target_manifest: selected.manifest.clone(), + base_label: blueprint.base_label, + base, + base_source, + base_includes, + overlays, + target_expansion: blueprint.targets, + media_declarations, + }) + } + + fn visit_target( + &self, + manifest_index: usize, + target: &str, + origin: TargetExpansionOrigin, + active: &mut Vec<(usize, String)>, + blueprint: &mut Blueprint, + ) -> Result<(), String> { + let key = (manifest_index, target.to_owned()); + if let Some(index) = active.iter().position(|candidate| candidate == &key) { + let mut cycle = active[index..] + .iter() + .map(|(index, target)| self.qualified_target(*index, target)) + .collect::>(); + cycle.push(self.qualified_target(manifest_index, target)); + return Err(format!( + "manifest target dependency cycle: {}", + cycle.join(" -> ") + )); + } + let loaded = &self.manifests[manifest_index]; + let target_entry = loaded.manifest.targets.get(target).ok_or_else(|| { + format!( + "manifest target {target:?} does not exist in {}; available targets: {}", + loaded.path.display(), + loaded + .manifest + .targets + .keys() + .cloned() + .collect::>() + .join(", ") + ) + })?; + let qualified = self.qualified_target(manifest_index, target); + blueprint.targets.push(TargetExpansion { + qualified_name: qualified.clone(), + owner: loaded.identity.clone(), + origin, + }); + active.push(key); + if let Some(extends) = &target_entry.extends { + let (base_index, base_target) = self.resolve_target_ref(manifest_index, extends)?; + self.visit_target( + base_index, + base_target, + TargetExpansionOrigin::Extends { + declaring_target: qualified.clone(), + reference: extends.clone(), + }, + active, + blueprint, + )?; + if blueprint.base_label.is_empty() { + blueprint.base_label = extends.clone(); + } + } else if blueprint.base_manifest.is_none() { + blueprint.base_manifest = Some(manifest_index); + blueprint.base_label = loaded.manifest.base.clone(); + } + for overlay in &target_entry.overlays { + self.visit_overlay( + manifest_index, + overlay, + OverlayExpansionOrigin::Target { + qualified_target: qualified.clone(), + reference: overlay.clone(), + }, + &mut Vec::new(), + blueprint, + )?; + } + active.pop(); + Ok(()) + } + + fn visit_overlay( + &self, + current_manifest_index: usize, + overlay_reference: &str, + origin: OverlayExpansionOrigin, + active: &mut Vec<(usize, String)>, + blueprint: &mut Blueprint, + ) -> Result<(), String> { + let (manifest_index, overlay_id) = + self.resolve_overlay_ref(current_manifest_index, overlay_reference)?; + let key = (manifest_index, overlay_id.to_owned()); + if blueprint.visited_overlays.contains(&key) { + return Ok(()); + } + if let Some(index) = active.iter().position(|candidate| candidate == &key) { + let mut cycle = active[index..] + .iter() + .map(|(index, id)| self.qualified_overlay(*index, id)) + .collect::>(); + cycle.push(self.qualified_overlay(manifest_index, overlay_id)); + return Err(format!( + "manifest overlay dependency cycle: {}", + cycle.join(" -> ") + )); + } + let loaded = &self.manifests[manifest_index]; + let entry = loaded.manifest.overlays.get(overlay_id).ok_or_else(|| { + format!( + "manifest overlay {overlay_id:?} does not exist in {}", + loaded.path.display() + ) + })?; + active.push(key.clone()); + let declaring = self.qualified_overlay(manifest_index, overlay_id); + for dependency in &entry.depends_on { + self.visit_overlay( + manifest_index, + dependency, + OverlayExpansionOrigin::Dependency { + declaring_overlay: declaring.clone(), + reference: dependency.clone(), + }, + active, + blueprint, + )?; + } + active.pop(); + blueprint.visited_overlays.insert(key); + blueprint.overlays.push(OverlayBlueprint { + manifest_index, + overlay_id: overlay_id.to_owned(), + origin, + }); + Ok(()) + } + + fn resolve_selected_target<'a>(&self, reference: &'a str) -> Result<(usize, &'a str), String> { + if let Some((package, target)) = split_qualified(reference)? { + let index = self.packages.get(package).copied().ok_or_else(|| { + format!("package {package:?} required by target ref {reference:?} was not included") + })?; + return Ok((index, target)); + } + let matches = self + .manifests + .iter() + .enumerate() + .filter(|(_, loaded)| loaded.manifest.targets.contains_key(reference)) + .map(|(index, _)| index) + .collect::>(); + match matches.as_slice() { + [] => Err(format!( + "manifest target {reference:?} does not exist; available targets: {}", + self.target_references().join(", ") + )), + [index] => Ok((*index, reference)), + _ => Err(format!( + "ambiguous unqualified target {reference:?}; use one of: {}", + matches + .iter() + .map(|index| self.qualified_target(*index, reference)) + .collect::>() + .join(", ") + )), + } + } + + fn resolve_target_ref<'a>( + &self, + current_manifest_index: usize, + reference: &'a str, + ) -> Result<(usize, &'a str), String> { + if let Some((package, target)) = split_qualified(reference)? { + let index = self.packages.get(package).copied().ok_or_else(|| { + format!("package {package:?} required by target ref {reference:?} was not included") + })?; + Ok((index, target)) + } else { + Ok((current_manifest_index, reference)) + } + } + + fn resolve_overlay_ref<'a>( + &self, + current_manifest_index: usize, + reference: &'a str, + ) -> Result<(usize, &'a str), String> { + if let Some((package, overlay)) = split_qualified(reference)? { + let index = self.packages.get(package).copied().ok_or_else(|| { + format!( + "package {package:?} required by overlay ref {reference:?} was not included" + ) + })?; + Ok((index, overlay)) + } else { + Ok((current_manifest_index, reference)) + } + } + + fn qualified_target(&self, manifest_index: usize, target: &str) -> String { + self.manifests[manifest_index] + .identity + .as_ref() + .map(|identity| format!("{}:{target}", identity.id)) + .unwrap_or_else(|| target.to_owned()) + } + + fn qualified_overlay(&self, manifest_index: usize, overlay: &str) -> String { + self.manifests[manifest_index] + .identity + .as_ref() + .map(|identity| format!("{}:{overlay}", identity.id)) + .unwrap_or_else(|| overlay.to_owned()) + } +} + +#[derive(Default)] +struct Blueprint { + base_manifest: Option, + base_label: String, + targets: Vec, + overlays: Vec, + visited_overlays: BTreeSet<(usize, String)>, +} + +struct OverlayBlueprint { + manifest_index: usize, + overlay_id: String, + origin: OverlayExpansionOrigin, +} + +fn canonical_manifest_path(path: &Path) -> Result { + fs::canonicalize(path).map_err(|error| format!("{}: {error}", path.display())) +} + +fn insert_candidate( + candidates: &mut BTreeMap, + path: PathBuf, + kind: RegistrySourceKind, +) -> bool { + match candidates.get_mut(&path) { + Some(existing) => { + if source_precedence(kind) < source_precedence(*existing) { + *existing = kind; + } + false + } + None => { + candidates.insert(path, kind); + true + } + } +} + +fn source_precedence(kind: RegistrySourceKind) -> u8 { + match kind { + RegistrySourceKind::RootManifest => 0, + RegistrySourceKind::ExplicitInclude => 1, + RegistrySourceKind::PackageRoot => 2, + RegistrySourceKind::SiblingLock => 3, + } +} + +fn validate_overlay_catalogs(manifests: &[RegistryManifest]) -> Result<(), String> { + let mut source_identities = BTreeMap::::new(); + for loaded in manifests { + for (catalog_id, entry) in &loaded.manifest.overlays { + let field = format!("overlays.{catalog_id}.file"); + let file = authorize_manifest_path(loaded, &field, &entry.file)?; + let qualified = loaded + .identity + .as_ref() + .map(|package| format!("{}:{catalog_id}", package.id)) + .unwrap_or_else(|| catalog_id.clone()); + let declared_identity = format!( + "{qualified} ({}) declared by {}", + entry.kind.as_deref().unwrap_or("unspecified kind"), + loaded.path.display() + ); + if let Some(previous) = + source_identities.insert(file.clone(), declared_identity.clone()) + && previous != declared_identity + { + return Err(format!( + "overlay source {} is cataloged under conflicting identities: {previous}; {declared_identity}", + file.display() + )); + } + let document = overlay_document_from_package( + &file, + &loaded.root, + &loaded.include_roots, + ) + .map_err(|error| { + format!( + "overlay catalog {catalog_id:?} declared in {} could not decode {}: {error}", + loaded.path.display(), + file.display() + ) + })?; + let overlay = document.resolved_overlay(); + if overlay.id.as_str() != catalog_id { + return Err(format!( + "overlay catalog identity mismatch in {} at overlays.{catalog_id}: catalog ID {catalog_id:?} points to {} whose overlay.id is {:?}", + loaded.path.display(), + file.display(), + overlay.id.as_str() + )); + } + if let Some(declared_kind) = &entry.kind { + let actual_kind = overlay_kind_name(overlay.kind); + if declared_kind != actual_kind { + return Err(format!( + "overlay catalog kind mismatch in {} at overlays.{catalog_id}.kind: declared {declared_kind:?}, but {} has overlay.kind {actual_kind:?}", + loaded.path.display(), + file.display() + )); + } + } + } + } + Ok(()) +} + +fn overlay_kind_name(kind: OverlayKind) -> &'static str { + match kind { + OverlayKind::Translation => "translation", + OverlayKind::Extension => "extension", + OverlayKind::Patch => "patch", + OverlayKind::Personal => "personal", + } +} + +fn authorize_manifest_path( + loaded: &RegistryManifest, + field: impl Into, + raw: &str, +) -> Result { + PathAuthorizer::new("package", &loaded.root)? + .authorize_read(&loaded.path, field, raw) + .map(|path| path.into_path_buf()) + .map_err(|error| error.to_string()) +} + +fn source_provenance( + loaded: &RegistryManifest, + path: PathBuf, + kind: PlanSourceKind, +) -> Result { + let bytes = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(SourceProvenance { + package: loaded.identity.clone(), + package_root: loaded.root.clone(), + manifest: loaded.path.clone(), + registry_source: loaded.discovery, + path, + kind, + sha256: format!("{:x}", Sha256::digest(bytes)), + }) +} + +fn included_provenance( + loaded: &RegistryManifest, + included: Vec, +) -> Result, String> { + included + .into_iter() + .map(|source| { + let kind = match source.kind() { + IncludedSourceKind::Scalar { schema_path } => PlanSourceKind::ScalarInclude { + schema_path: schema_path.clone(), + }, + IncludedSourceKind::MediaDeclarations => PlanSourceKind::MediaInclude, + }; + source_provenance(loaded, provenance_path(source.provenance())?, kind) + }) + .collect() +} + +fn provenance_path(provenance: &DocumentProvenance) -> Result { + let path = PathBuf::from(provenance.source_name()); + if path.is_absolute() { + Ok(path) + } else { + Err(format!( + "source provenance {} is not an absolute authorized path", + provenance + )) + } +} + +fn media_owner( + loaded: &RegistryManifest, + source: &Path, + document_source: &Path, + media: &brain_brew_core::MediaReference, +) -> MediaDeclarationProvenance { + MediaDeclarationProvenance { + id: media.id.to_string(), + path: media.path.clone(), + sha256: media.sha256.clone(), + package: loaded.identity.clone(), + package_root: loaded.root.clone(), + source_kind: loaded.discovery, + source: source.to_path_buf(), + document_source: document_source.to_path_buf(), + } +} + +fn validate_media_declaration_collisions( + target: &str, + declarations: &BTreeMap, +) -> Result<(), String> { + let mut paths = BTreeMap::<&str, &MediaDeclarationProvenance>::new(); + for declaration in declarations.values() { + if let Some(previous) = paths.insert(&declaration.path, declaration) + && (previous.package_root != declaration.package_root + || previous.sha256 != declaration.sha256) + { + return Err(format!( + "media path/output collision for target {target}: declaration {} owned by package {} at {} and declaration {} owned by package {} at {} both resolve to {:?}", + previous.id, + previous.package_label(), + previous.source.display(), + declaration.id, + declaration.package_label(), + declaration.source.display(), + declaration.path + )); + } + } + Ok(()) +} + +fn bind_media_references( + target: &str, + deck: &CanonicalDeck, + declarations: &BTreeMap, +) -> Result, String> { + let report = media::reference_report(deck); + if !report.errors.is_empty() { + return Err(format!( + "media reference ownership failed for target {target}: {}", + report + .errors + .iter() + .map(|error| error.message.clone()) + .collect::>() + .join("; ") + )); + } + + let mut bindings = BTreeMap::::new(); + for note in deck.notes.values() { + for value in note.fields.values() { + let FieldValue::Images(images) = value else { + continue; + }; + for image in images { + let declaration = declarations.get(image.media_id.as_str()).ok_or_else(|| { + format!( + "media reference ownership failed for target {target}: unknown stable ID {}", + image.media_id + ) + })?; + let reference = format!("id:{}", image.media_id); + bindings.insert( + reference.clone(), + MediaReferenceBinding { + reference, + declaration: declaration.clone(), + }, + ); + } + } + } + for path in media::referenced_paths(deck) { + let candidates = declarations + .values() + .filter(|declaration| declaration.path == path) + .collect::>(); + let Some(first) = candidates.first() else { + return Err(format!( + "media reference ownership failed for target {target}: path {path:?} is undeclared" + )); + }; + // Same-package aliases are the compatibility policy for historical maps: + // they are unambiguous only when they select the exact same owner root, + // path, and hash bytes. Cross-package aliases are rejected earlier. + if candidates.iter().skip(1).any(|candidate| { + candidate.package_root != first.package_root + || candidate.path != first.path + || candidate.sha256 != first.sha256 + }) { + return Err(format!( + "ambiguous media reference ownership for target {target}, path {path:?}: {}", + candidates + .iter() + .map(|candidate| format!( + "{} (package {}, hash {:?}, source {})", + candidate.id, + candidate.package_label(), + candidate.sha256, + candidate.source.display() + )) + .collect::>() + .join(", ") + )); + } + let reference = format!("path:{path}"); + bindings.insert( + reference.clone(), + MediaReferenceBinding { + reference, + declaration: (*first).clone(), + }, + ); + } + Ok(bindings.into_values().collect()) +} + +fn split_qualified(reference: &str) -> Result, String> { + let Some((package, item)) = reference.split_once(':') else { + return Ok(None); + }; + if package.is_empty() || item.is_empty() || item.contains(':') { + return Err(format!( + "invalid package-qualified reference {reference:?}; expected :" + )); + } + Ok(Some((package, item))) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn plan_retains_package_include_overlay_and_media_provenance() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("description.md"), "!include nested.md\n").unwrap(); + fs::write(root.path().join("nested.md"), "Included description\n").unwrap(); + fs::write( + root.path().join("media.yaml"), + "media.flag:\n path: flag.svg\n sha256: ''\n", + ) + .unwrap(); + fs::write( + root.path().join("deck.yaml"), + "deck:\n id: deck.example\n name: Example\n description: !include description.md\nnote_types: {}\nnotes: {}\nmedia: !include media.yaml\ntombstones: []\n", + ) + .unwrap(); + fs::write( + root.path().join("patch.yaml"), + "id: overlay.patch\nkind: patch\ndeck:\n name:\n intent: replace\n value: Patched\n expected_base:\n value: Example\n", + ) + .unwrap(); + fs::write( + root.path().join("brainbrew.yaml"), + "package:\n id: example.deck\n version: 1.0.0\nbase: deck.yaml\noverlays:\n overlay.patch:\n file: patch.yaml\n kind: patch\ntargets:\n patched:\n overlays:\n - overlay.patch\n", + ) + .unwrap(); + + let registry = ManifestRegistry::load(&root.path().join("brainbrew.yaml"), &[], &[]) + .expect("registry loads"); + let plan = registry + .plan("example.deck:patched") + .expect("qualified target plans"); + + assert_eq!(plan.qualified_name, "example.deck:patched"); + assert_eq!(plan.owner.as_ref().unwrap().id, "example.deck"); + assert_eq!(plan.base_includes.len(), 3); + assert_eq!( + plan.base_includes + .iter() + .filter(|source| matches!(source.kind, PlanSourceKind::ScalarInclude { .. })) + .count(), + 2 + ); + assert!(plan.base_includes.iter().all(|source| { + !matches!( + source.kind, + PlanSourceKind::ScalarInclude { ref schema_path } + if schema_path != "deck.description" + ) + })); + assert!( + plan.base_includes + .iter() + .any(|source| source.kind == PlanSourceKind::MediaInclude) + ); + assert_eq!(plan.overlays[0].0.kind, OverlayKind::Patch); + assert!(matches!( + plan.overlays[0].0.origin, + OverlayExpansionOrigin::Target { .. } + )); + let media = &plan.media_declarations["media.flag"]; + assert_eq!(media.package.as_ref().unwrap().id, "example.deck"); + assert_eq!(media.package_root, fs::canonicalize(root.path()).unwrap()); + assert_eq!( + media.source, + fs::canonicalize(root.path().join("media.yaml")).unwrap() + ); + assert_eq!(plan.compose().unwrap().name, "Patched"); + } +} diff --git a/crates/brain-brew-cli/src/workspace_mutation.rs b/crates/brain-brew-cli/src/workspace_mutation.rs new file mode 100644 index 0000000..7b33d2d --- /dev/null +++ b/crates/brain-brew-cli/src/workspace_mutation.rs @@ -0,0 +1,318 @@ +//! Shared plan-first adapter for recoverable source-file replacements. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::workspace_transaction::{ + ExpectedTarget, FailureInjector, FailurePoint, FileFingerprint, InjectedFailure, NoFailures, + RealWorkspaceFilesystem, ValidatedReplacement, WorkspaceTransactionManager, + WorkspaceTransactionPlan, WorkspaceWrite, +}; + +/// One replacement whose original bytes were observed while the complete mutation was planned. +pub(crate) struct PlannedWorkspaceFile { + path: PathBuf, + expected: ExpectedTarget, + replacement: ValidatedReplacement, +} + +impl PlannedWorkspaceFile { + pub(crate) fn validated( + path: impl Into, + original: Vec, + replacement: Vec, + validator: impl FnOnce(&[u8]) -> Result<(), String>, + ) -> Result { + Self::validated_with_expected( + path, + ExpectedTarget::Present(FileFingerprint::for_bytes(&original)), + replacement, + validator, + ) + } + + pub(crate) fn validated_new( + path: impl Into, + replacement: Vec, + validator: impl FnOnce(&[u8]) -> Result<(), String>, + ) -> Result { + Self::validated_with_expected(path, ExpectedTarget::Absent, replacement, validator) + } + + fn validated_with_expected( + path: impl Into, + expected: ExpectedTarget, + replacement: Vec, + validator: impl FnOnce(&[u8]) -> Result<(), String>, + ) -> Result { + let replacement = ValidatedReplacement::validate(replacement, validator) + .map_err(|error| error.to_string())?; + Ok(Self { + path: path.into(), + expected, + replacement, + }) + } +} + +/// Recover interrupted writes before a mutator reads or plans against the workspace. +/// Publish one generated output file beneath its nearest caller-selected existing +/// ancestor. New outputs create missing parents; existing outputs require force. +pub(crate) fn write_output_file( + path: &Path, + bytes: Vec, + force: bool, + validator: impl FnOnce(&[u8]) -> Result<(), String>, +) -> Result<(), String> { + let requested = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| format!("cannot resolve current directory: {error}"))? + .join(path) + }; + let root = nearest_existing_ancestor( + requested + .parent() + .ok_or_else(|| format!("output {} has no parent directory", path.display()))?, + )?; + recover_workspace(&root)?; + + let planned = match std::fs::symlink_metadata(&requested) { + Ok(metadata) if !metadata.file_type().is_file() => { + return Err(format!( + "refusing to replace non-file output {}", + requested.display() + )); + } + Ok(_) if !force => { + return Err(format!( + "refusing to overwrite existing output {}; pass --force to replace it recoverably", + requested.display() + )); + } + Ok(_) => { + let original = std::fs::read(&requested) + .map_err(|error| format!("{}: {error}", requested.display()))?; + PlannedWorkspaceFile::validated(requested, original, bytes, validator)? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + PlannedWorkspaceFile::validated_new(requested, bytes, validator)? + } + Err(error) => return Err(format!("{}: {error}", requested.display())), + }; + commit_workspace_files(&root, vec![planned]) +} + +pub(crate) fn nearest_existing_ancestor(path: &Path) -> Result { + let mut cursor = path; + loop { + match std::fs::symlink_metadata(cursor) { + Ok(metadata) if !metadata.file_type().is_dir() => { + return Err(format!( + "output parent {} is not a directory", + cursor.display() + )); + } + Ok(_) => { + return std::fs::canonicalize(cursor) + .map_err(|error| format!("{}: {error}", cursor.display())); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + cursor = cursor.parent().ok_or_else(|| { + format!("output parent {} has no existing ancestor", path.display()) + })?; + } + Err(error) => return Err(format!("{}: {error}", cursor.display())), + } + } +} + +pub(crate) fn recover_workspace(workspace_root: &Path) -> Result<(), String> { + WorkspaceTransactionManager::new(&RealWorkspaceFilesystem, &NoFailures) + .recover(workspace_root) + .map(|_| ()) + .map_err(|error| format!("workspace transaction recovery failed: {error}")) +} + +/// Validate the complete replacement set, then commit it through the durable journal. +pub(crate) fn commit_workspace_files( + workspace_root: &Path, + files: Vec, +) -> Result<(), String> { + if files.is_empty() { + return Ok(()); + } + + let root = std::fs::canonicalize(workspace_root) + .map_err(|error| format!("{}: {error}", workspace_root.display()))?; + let created_directories = create_missing_target_parents(&root, &files)?; + let result = (|| { + let writes = files + .into_iter() + .map(|file| { + let absolute = match &file.expected { + ExpectedTarget::Present(_) => std::fs::canonicalize(&file.path) + .map_err(|error| format!("{}: {error}", file.path.display()))?, + ExpectedTarget::Absent => { + let parent = file.path.parent().ok_or_else(|| { + format!("{} has no parent directory", file.path.display()) + })?; + let parent = std::fs::canonicalize(parent) + .map_err(|error| format!("{}: {error}", parent.display()))?; + let name = file + .path + .file_name() + .ok_or_else(|| format!("{} has no file name", file.path.display()))?; + parent.join(name) + } + }; + let target = absolute.strip_prefix(&root).map_err(|_| { + format!( + "workspace transaction target {} is outside {}", + absolute.display(), + root.display() + ) + })?; + Ok(WorkspaceWrite::new(target, file.expected, file.replacement)) + }) + .collect::, String>>()?; + let transaction = WorkspaceTransactionPlan::new(&root, writes) + .validate(&RealWorkspaceFilesystem) + .map_err(|error| format!("workspace transaction plan is invalid: {error}"))?; + transaction_trace("transaction_begin"); + let result = + WorkspaceTransactionManager::new(&RealWorkspaceFilesystem, &EnvironmentFailures) + .commit(transaction) + .map_err(|error| format!("workspace transaction commit failed: {error}")); + transaction_trace("transaction_end"); + result + })(); + if result.is_err() { + remove_empty_directories(&created_directories); + } + result +} + +fn create_missing_target_parents( + root: &Path, + files: &[PlannedWorkspaceFile], +) -> Result, String> { + let mut required = Vec::new(); + for file in files { + if !matches!(file.expected, ExpectedTarget::Absent) { + continue; + } + let parent = file + .path + .parent() + .ok_or_else(|| format!("{} has no parent directory", file.path.display()))?; + let absolute = if parent.is_absolute() { + parent.to_path_buf() + } else { + root.join(parent) + }; + if !absolute.starts_with(root) { + return Err(format!( + "workspace transaction target parent {} is outside {}", + absolute.display(), + root.display() + )); + } + let mut missing = Vec::new(); + let mut cursor = absolute.as_path(); + while !cursor.exists() { + missing.push(cursor.to_path_buf()); + cursor = cursor + .parent() + .ok_or_else(|| format!("{} has no existing ancestor", absolute.display()))?; + } + let ancestor = std::fs::canonicalize(cursor) + .map_err(|error| format!("{}: {error}", cursor.display()))?; + if !ancestor.starts_with(root) { + return Err(format!( + "workspace transaction target parent {} escapes {} through {}", + absolute.display(), + root.display(), + ancestor.display() + )); + } + missing.reverse(); + required.extend(missing); + } + required.sort(); + required.dedup(); + let mut created = Vec::new(); + for directory in required { + if directory.exists() { + continue; + } + if let Err(error) = std::fs::create_dir(&directory) { + remove_empty_directories(&created); + return Err(format!("{}: {error}", directory.display())); + } + created.push(directory); + } + Ok(created) +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentFailures; + +impl FailureInjector for EnvironmentFailures { + fn check(&self, point: &FailurePoint) -> Option { + if let FailurePoint::Replace(_) = point + && let Ok(value) = std::env::var("BRAINBREW_TRANSACTION_SLEEP_BEFORE_REPLACE_MS") + && let Ok(milliseconds) = value.parse::() + && milliseconds > 0 + { + std::thread::sleep(std::time::Duration::from_millis(milliseconds)); + } + let requested = std::env::var("BRAINBREW_TRANSACTION_FAIL_POINT").ok()?; + let actual = failure_point_name(point); + if requested != actual { + return None; + } + Some( + if std::env::var("BRAINBREW_TRANSACTION_FAIL_MODE").as_deref() == Ok("crash") { + InjectedFailure::Crash + } else { + InjectedFailure::Error + }, + ) + } +} + +fn failure_point_name(point: &FailurePoint) -> String { + match point { + FailurePoint::CreateJournal => "create-journal".to_owned(), + FailurePoint::StageReplacement(index) => format!("stage:{index}"), + FailurePoint::Backup(index) => format!("backup:{index}"), + FailurePoint::MarkPrepared => "mark-prepared".to_owned(), + FailurePoint::Replace(index) => format!("replace:{index}"), + FailurePoint::RecordCommit(index) => format!("record-commit:{index}"), + FailurePoint::MarkCommitted => "mark-committed".to_owned(), + FailurePoint::FinalizeCleanup => "finalize".to_owned(), + FailurePoint::Rollback(index) => format!("rollback:{index}"), + FailurePoint::PublishRollbackRestore(index) => format!("rollback-restore:{index}"), + FailurePoint::RecordRollback(index) => format!("record-rollback:{index}"), + FailurePoint::MarkRolledBack => "mark-rolled-back".to_owned(), + } +} + +fn transaction_trace(event: &str) { + let Ok(path) = std::env::var("BRAINBREW_TRANSACTION_TRACE") else { + return; + }; + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| writeln!(file, "{event}")); +} + +fn remove_empty_directories(directories: &[PathBuf]) { + for directory in directories.iter().rev() { + let _ = std::fs::remove_dir(directory); + } +} diff --git a/crates/brain-brew-cli/src/workspace_transaction.rs b/crates/brain-brew-cli/src/workspace_transaction.rs new file mode 100644 index 0000000..a9fd489 --- /dev/null +++ b/crates/brain-brew-cli/src/workspace_transaction.rs @@ -0,0 +1,2329 @@ +//! Journaled, recoverable transactions for writes under one workspace root. +//! +//! This module deliberately does not claim that a sequence of renames is atomic. A transaction +//! makes each replacement individually atomic, records enough durable state to undo every +//! replacement, and requires restart recovery before another cooperating writer may proceed. + +// Failure-injection seams remain test-only while command families migrate incrementally. +#![allow(dead_code)] + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::safe_relative_path::SafeRelativePath; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const CONTROL_DIRECTORY: &str = ".brainbrew-transactions"; +const JOURNAL_FILE: &str = "journal.json"; +const JOURNAL_TEMP_FILE: &str = "journal.next"; +const LOCK_FILE: &str = "workspace.lock"; +const JOURNAL_VERSION: u32 = 1; +static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// SHA-256 and byte length used for optimistic concurrency checks. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub(crate) struct FileFingerprint { + pub(crate) sha256: String, + pub(crate) length: u64, +} + +impl FileFingerprint { + pub(crate) fn for_bytes(bytes: &[u8]) -> Self { + Self { + sha256: format!("{:x}", Sha256::digest(bytes)), + length: bytes.len() as u64, + } + } +} + +/// The state a caller observed while constructing a transaction plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ExpectedTarget { + Absent, + Present(FileFingerprint), +} + +/// Replacement bytes that have passed the caller's format/domain validation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ValidatedReplacement { + bytes: Vec, + new_file_mode: u32, +} + +impl ValidatedReplacement { + pub(crate) fn validate( + bytes: Vec, + validator: impl FnOnce(&[u8]) -> Result<(), String>, + ) -> Result { + validator(&bytes).map_err(|message| TransactionError::InvalidReplacement { message })?; + Ok(Self { + bytes, + new_file_mode: 0o644, + }) + } + + pub(crate) fn with_new_file_mode(mut self, mode: u32) -> Result { + if mode & !0o7777 != 0 { + return Err(TransactionError::InvalidReplacement { + message: format!("invalid Unix file mode {mode:#o}"), + }); + } + self.new_file_mode = mode; + Ok(self) + } +} + +/// One file replacement, addressed relative to the canonical workspace root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceWrite { + pub(crate) target: PathBuf, + pub(crate) expected: ExpectedTarget, + pub(crate) replacement: ValidatedReplacement, +} + +impl WorkspaceWrite { + pub(crate) fn new( + target: impl Into, + expected: ExpectedTarget, + replacement: ValidatedReplacement, + ) -> Self { + Self { + target: target.into(), + expected, + replacement, + } + } +} + +/// An unvalidated transaction plan. Validation performs no filesystem mutation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceTransactionPlan { + pub(crate) workspace_root: PathBuf, + pub(crate) writes: Vec, +} + +impl WorkspaceTransactionPlan { + pub(crate) fn new(workspace_root: impl Into, writes: Vec) -> Self { + Self { + workspace_root: workspace_root.into(), + writes, + } + } + + pub(crate) fn validate( + self, + filesystem: &dyn WorkspaceFilesystem, + ) -> Result { + validate_plan(self, filesystem) + } +} + +/// A complete, immutable plan whose paths, expected state, and filesystem have been checked. +#[derive(Clone, Debug)] +pub(crate) struct ValidatedWorkspaceTransaction { + workspace_root: PathBuf, + root_filesystem: u64, + writes: Vec, +} + +#[derive(Clone, Debug)] +struct ValidatedWrite { + relative_target: String, + canonical_parent: PathBuf, + absolute_target: PathBuf, + expected: ExpectedTarget, + replacement: ValidatedReplacement, + original_mode: Option, +} + +/// File type and platform metadata needed by transaction validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WorkspaceMetadata { + pub(crate) kind: WorkspaceFileKind, + pub(crate) filesystem: u64, + pub(crate) mode: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WorkspaceFileKind { + File, + Directory, + Symlink, + Other, +} + +/// An acquired cooperative workspace lock. Dropping it releases the lock. +pub(crate) trait WorkspaceLock: Send {} + +/// Injectable filesystem boundary used by validation, commit, rollback, and recovery. +pub(crate) trait WorkspaceFilesystem: Send + Sync { + fn canonicalize(&self, path: &Path) -> io::Result; + fn metadata(&self, path: &Path) -> io::Result; + fn symlink_metadata(&self, path: &Path) -> io::Result; + fn read(&self, path: &Path) -> io::Result>; + fn read_directories(&self, path: &Path) -> io::Result>; + fn create_dir_all(&self, path: &Path) -> io::Result<()>; + fn write_new_durable(&self, path: &Path, bytes: &[u8], mode: u32) -> io::Result<()>; + fn replace_durable(&self, source: &Path, target: &Path) -> io::Result<()>; + /// Publish a staged file only if the target is still absent. + fn publish_new_durable(&self, source: &Path, target: &Path) -> io::Result<()>; + fn remove_file_durable(&self, path: &Path) -> io::Result<()>; + fn remove_dir_all_durable(&self, path: &Path) -> io::Result<()>; + fn sync_directory(&self, path: &Path) -> io::Result<()>; + fn try_lock(&self, path: &Path) -> io::Result>; +} + +/// Production filesystem adapter. Unix device IDs and modes enforce the one-filesystem contract. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct RealWorkspaceFilesystem; + +struct RealWorkspaceLock(File); +impl WorkspaceLock for RealWorkspaceLock {} + +impl Drop for RealWorkspaceLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + +impl WorkspaceFilesystem for RealWorkspaceFilesystem { + fn canonicalize(&self, path: &Path) -> io::Result { + fs::canonicalize(path) + } + + fn metadata(&self, path: &Path) -> io::Result { + real_metadata(fs::metadata(path)?) + } + + fn symlink_metadata(&self, path: &Path) -> io::Result { + real_metadata(fs::symlink_metadata(path)?) + } + + fn read(&self, path: &Path) -> io::Result> { + fs::read(path) + } + + fn read_directories(&self, path: &Path) -> io::Result> { + let mut directories = fs::read_dir(path)? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(|kind| kind.is_dir()) + .map(|_| entry.path()) + }) + .collect::>(); + directories.sort(); + Ok(directories) + } + + fn create_dir_all(&self, path: &Path) -> io::Result<()> { + fs::create_dir_all(path) + } + + fn write_new_durable(&self, path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> { + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + set_file_mode(&file, mode)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + self.sync_directory(parent(path)?) + } + + fn replace_durable(&self, source: &Path, target: &Path) -> io::Result<()> { + fs::rename(source, target)?; + self.sync_directory(parent(target)?) + } + + fn publish_new_durable(&self, source: &Path, target: &Path) -> io::Result<()> { + fs::hard_link(source, target)?; + self.sync_directory(parent(target)?)?; + fs::remove_file(source)?; + self.sync_directory(parent(source)?) + } + + fn remove_file_durable(&self, path: &Path) -> io::Result<()> { + fs::remove_file(path)?; + self.sync_directory(parent(path)?) + } + + fn remove_dir_all_durable(&self, path: &Path) -> io::Result<()> { + fs::remove_dir_all(path)?; + self.sync_directory(parent(path)?) + } + + fn sync_directory(&self, path: &Path) -> io::Result<()> { + File::open(path)?.sync_all() + } + + fn try_lock(&self, path: &Path) -> io::Result> { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.try_lock_exclusive()?; + Ok(Box::new(RealWorkspaceLock(file))) + } +} + +#[cfg(unix)] +fn real_metadata(metadata: fs::Metadata) -> io::Result { + use std::os::unix::fs::MetadataExt; + let file_type = metadata.file_type(); + let kind = if file_type.is_file() { + WorkspaceFileKind::File + } else if file_type.is_dir() { + WorkspaceFileKind::Directory + } else if file_type.is_symlink() { + WorkspaceFileKind::Symlink + } else { + WorkspaceFileKind::Other + }; + Ok(WorkspaceMetadata { + kind, + filesystem: metadata.dev(), + mode: metadata.mode() & 0o7777, + }) +} + +#[cfg(not(unix))] +fn real_metadata(_metadata: fs::Metadata) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "workspace transactions require Unix filesystem identity and permission metadata", + )) +} + +#[cfg(unix)] +fn set_file_mode(file: &File, mode: u32) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(mode)) +} + +#[cfg(not(unix))] +fn set_file_mode(_file: &File, _mode: u32) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "workspace transactions require Unix permission metadata", + )) +} + +fn parent(path: &Path) -> io::Result<&Path> { + path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{} has no parent directory", path.display()), + ) + }) +} + +/// A deterministic semantic failure point. `Crash` simulates process loss and skips auto-rollback. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum FailurePoint { + CreateJournal, + StageReplacement(usize), + Backup(usize), + MarkPrepared, + Replace(usize), + RecordCommit(usize), + MarkCommitted, + FinalizeCleanup, + Rollback(usize), + PublishRollbackRestore(usize), + RecordRollback(usize), + MarkRolledBack, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InjectedFailure { + Error, + Crash, +} + +pub(crate) trait FailureInjector: Send + Sync { + fn check(&self, point: &FailurePoint) -> Option; +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NoFailures; + +impl FailureInjector for NoFailures { + fn check(&self, _point: &FailurePoint) -> Option { + None + } +} + +/// Result of recovery for one durable transaction journal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RecoveryAction { + RolledBack { transaction_id: String }, + FinalizedCommit { transaction_id: String }, +} + +/// Transaction coordinator. All cooperating writers use one advisory lock per workspace root. +pub(crate) struct WorkspaceTransactionManager<'a> { + filesystem: &'a dyn WorkspaceFilesystem, + failures: &'a dyn FailureInjector, +} + +impl<'a> WorkspaceTransactionManager<'a> { + pub(crate) fn new( + filesystem: &'a dyn WorkspaceFilesystem, + failures: &'a dyn FailureInjector, + ) -> Self { + Self { + filesystem, + failures, + } + } + + pub(crate) fn commit( + &self, + transaction: ValidatedWorkspaceTransaction, + ) -> Result<(), TransactionError> { + let control = transaction.workspace_root.join(CONTROL_DIRECTORY); + self.ensure_control_directory( + &transaction.workspace_root, + transaction.root_filesystem, + &control, + )?; + let _lock = self.acquire_lock(&control)?; + self.ensure_no_pending_transactions(&control)?; + self.revalidate(&transaction)?; + + let transaction_id = transaction_id(); + let transaction_directory = control.join(&transaction_id); + let replacements = transaction_directory.join("replacements"); + let backups = transaction_directory.join("backups"); + self.filesystem + .create_dir_all(&replacements) + .and_then(|()| self.filesystem.create_dir_all(&backups)) + .map_err(|source| { + io_error( + "create transaction directories", + &transaction_directory, + source, + ) + })?; + self.filesystem + .sync_directory(&control) + .map_err(|source| io_error("sync transaction control directory", &control, source))?; + + let mut journal = Journal::new(&transaction_id, &transaction); + let journal_path = transaction_directory.join(JOURNAL_FILE); + if let Err(failure) = self.inject(&FailurePoint::CreateJournal) { + return Err(self.interrupted(transaction_id, journal.state, failure)); + } + self.write_initial_journal(&journal_path, &journal)?; + + self.prepare_complete_plan( + &transaction, + &transaction_directory, + &mut journal, + &journal_path, + )?; + + // Catch non-cooperating edits made while replacements and backups were prepared. + self.revalidate(&transaction)?; + if let Err(failure) = self.inject(&FailurePoint::MarkPrepared) { + return Err(self.interrupted(transaction_id, journal.state, failure)); + } + journal.state = JournalState::Prepared; + self.persist_journal(&journal_path, &journal)?; + + journal.state = JournalState::Committing { completed: 0 }; + self.persist_journal(&journal_path, &journal)?; + for index in 0..transaction.writes.len() { + if let Err(failure) = self.inject(&FailurePoint::Replace(index)) { + return self.handle_commit_failure( + &journal_path, + &transaction_directory, + &mut journal, + failure, + ); + } + let staged = replacements.join(index.to_string()); + let write = &transaction.writes[index]; + let target = &write.absolute_target; + if let Err(error) = validate_current_target(self.filesystem, write) { + return self.handle_commit_error( + &journal_path, + &transaction_directory, + &mut journal, + error, + ); + } + let publish = match &write.expected { + ExpectedTarget::Absent => self.filesystem.publish_new_durable(&staged, target), + ExpectedTarget::Present(_) => self.filesystem.replace_durable(&staged, target), + }; + if let Err(source) = publish { + return self.handle_commit_error( + &journal_path, + &transaction_directory, + &mut journal, + io_error("publish replacement", target, source), + ); + } + if let Err(failure) = self.inject(&FailurePoint::RecordCommit(index)) { + return self.handle_commit_failure( + &journal_path, + &transaction_directory, + &mut journal, + failure, + ); + } + journal.state = JournalState::Committing { + completed: index + 1, + }; + if let Err(error) = self.persist_journal(&journal_path, &journal) { + return self.handle_commit_error( + &journal_path, + &transaction_directory, + &mut journal, + error, + ); + } + } + + if let Err(failure) = self.inject(&FailurePoint::MarkCommitted) { + return self.handle_commit_failure( + &journal_path, + &transaction_directory, + &mut journal, + failure, + ); + } + journal.state = JournalState::Committed; + self.persist_journal(&journal_path, &journal)?; + if let Err(failure) = self.inject(&FailurePoint::FinalizeCleanup) { + return Err(self.interrupted(transaction_id, journal.state, failure)); + } + self.cleanup_transaction(&transaction_directory)?; + Ok(()) + } + + /// Recover every transaction under a workspace root while holding the cooperative lock. + pub(crate) fn recover( + &self, + workspace_root: &Path, + ) -> Result, TransactionError> { + let root = self + .filesystem + .canonicalize(workspace_root) + .map_err(|source| io_error("canonicalize workspace root", workspace_root, source))?; + let control = root.join(CONTROL_DIRECTORY); + let root_filesystem = self + .filesystem + .metadata(&root) + .map_err(|source| io_error("inspect recovery root", &root, source))? + .filesystem; + match self.filesystem.symlink_metadata(&control) { + Ok(metadata) if metadata.kind == WorkspaceFileKind::Directory => { + self.validate_control_directory(&root, root_filesystem, &control, metadata)?; + } + Ok(metadata) => { + return Err(TransactionError::UnsafeControlPath { + path: control, + reason: format!("control path has unsupported type {:?}", metadata.kind), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(source) => { + return Err(io_error( + "inspect transaction control directory", + &control, + source, + )); + } + } + let _lock = self.acquire_lock(&control)?; + let mut actions = Vec::new(); + for directory in self + .filesystem + .read_directories(&control) + .map_err(|source| io_error("list transaction journals", &control, source))? + { + let journal_path = directory.join(JOURNAL_FILE); + if !path_exists(self.filesystem, &journal_path)? { + // A crash before the initial journal cannot have replaced a target. + let id = directory + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_owned(); + self.cleanup_transaction(&directory)?; + actions.push(RecoveryAction::RolledBack { transaction_id: id }); + continue; + } + let mut journal = self.read_journal(&journal_path)?; + self.validate_recovery_journal(&root, &directory, &journal)?; + match journal.state { + JournalState::Committed => { + self.verify_journal_tree(&journal, RecoveryTree::Replacement)?; + let id = journal.transaction_id.clone(); + self.cleanup_transaction(&directory)?; + actions.push(RecoveryAction::FinalizedCommit { transaction_id: id }); + } + JournalState::RolledBack => { + self.verify_journal_tree(&journal, RecoveryTree::Original)?; + let id = journal.transaction_id.clone(); + self.cleanup_transaction(&directory)?; + actions.push(RecoveryAction::RolledBack { transaction_id: id }); + } + JournalState::Preparing | JournalState::Prepared => { + // No target replacement is legal before Committing is durable. Verify that + // invariant instead of requiring backups that may still be in preparation. + for entry in &journal.entries { + let target = root.join(&entry.target); + let current = self.current_fingerprint(&target)?; + if current != entry.original { + return Err(TransactionError::RecoveryConflict { + path: target, + expected_original: entry.original.clone(), + expected_replacement: entry.replacement.clone(), + actual: current, + }); + } + } + let id = journal.transaction_id.clone(); + journal.state = JournalState::RolledBack; + self.persist_journal(&journal_path, &journal)?; + self.cleanup_transaction(&directory)?; + actions.push(RecoveryAction::RolledBack { transaction_id: id }); + } + JournalState::Committing { .. } | JournalState::RollingBack { .. } => { + self.rollback(&journal_path, &directory, &mut journal)?; + let id = journal.transaction_id.clone(); + self.cleanup_transaction(&directory)?; + actions.push(RecoveryAction::RolledBack { transaction_id: id }); + } + } + } + Ok(actions) + } + + fn prepare_complete_plan( + &self, + transaction: &ValidatedWorkspaceTransaction, + transaction_directory: &Path, + journal: &mut Journal, + journal_path: &Path, + ) -> Result<(), TransactionError> { + for (index, write) in transaction.writes.iter().enumerate() { + if let Err(failure) = self.inject(&FailurePoint::StageReplacement(index)) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + let staged = transaction_directory + .join("replacements") + .join(index.to_string()); + let mode = write + .original_mode + .unwrap_or(write.replacement.new_file_mode); + self.filesystem + .write_new_durable(&staged, &write.replacement.bytes, mode) + .map_err(|source| io_error("stage replacement", &staged, source))?; + journal.prepared_replacements = index + 1; + self.persist_journal(journal_path, journal)?; + } + for (index, write) in transaction.writes.iter().enumerate() { + if write.original_mode.is_none() { + journal.prepared_backups = index + 1; + self.persist_journal(journal_path, journal)?; + continue; + } + if let Err(failure) = self.inject(&FailurePoint::Backup(index)) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + let backup = transaction_directory + .join("backups") + .join(index.to_string()); + let bytes = self + .filesystem + .read(&write.absolute_target) + .map_err(|source| { + io_error("read target for backup", &write.absolute_target, source) + })?; + let actual = FileFingerprint::for_bytes(&bytes); + match &write.expected { + ExpectedTarget::Present(expected) if *expected == actual => {} + _ => { + return Err(TransactionError::ExpectedStateChanged { + path: write.absolute_target.clone(), + }); + } + } + self.filesystem + .write_new_durable(&backup, &bytes, write.original_mode.unwrap_or(0o600)) + .map_err(|source| io_error("write transaction backup", &backup, source))?; + journal.prepared_backups = index + 1; + self.persist_journal(journal_path, journal)?; + } + Ok(()) + } + + fn handle_commit_failure( + &self, + journal_path: &Path, + transaction_directory: &Path, + journal: &mut Journal, + failure: InjectedFailure, + ) -> Result<(), TransactionError> { + if failure == InjectedFailure::Crash { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + let original = TransactionError::Injected { + point: "commit".to_owned(), + recoverable_transaction: Some(journal.transaction_id.clone()), + }; + self.rollback(journal_path, transaction_directory, journal) + .and_then(|()| self.cleanup_transaction(transaction_directory)) + .map_or_else(Err, |()| { + Err(TransactionError::RolledBack { + transaction_id: journal.transaction_id.clone(), + cause: Box::new(original), + }) + }) + } + + fn handle_commit_error( + &self, + journal_path: &Path, + transaction_directory: &Path, + journal: &mut Journal, + original: TransactionError, + ) -> Result<(), TransactionError> { + self.rollback(journal_path, transaction_directory, journal) + .and_then(|()| self.cleanup_transaction(transaction_directory)) + .map_or_else(Err, |()| { + Err(TransactionError::RolledBack { + transaction_id: journal.transaction_id.clone(), + cause: Box::new(original), + }) + }) + } + + fn rollback( + &self, + journal_path: &Path, + transaction_directory: &Path, + journal: &mut Journal, + ) -> Result<(), TransactionError> { + journal.state = JournalState::RollingBack { + remaining: journal.entries.len(), + }; + self.persist_journal(journal_path, journal)?; + for index in (0..journal.entries.len()).rev() { + if let Err(failure) = self.inject(&FailurePoint::Rollback(index)) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + let entry = &journal.entries[index]; + let target = journal.workspace_root.join(&entry.target); + self.assert_recoverable_target(&target, entry)?; + if entry.original.is_some() { + let backup = transaction_directory + .join("backups") + .join(index.to_string()); + let rollback_stage = transaction_directory + .join("backups") + .join(format!("{index}.restore")); + let bytes = self + .filesystem + .read(&backup) + .map_err(|source| io_error("read rollback backup", &backup, source))?; + let fingerprint = FileFingerprint::for_bytes(&bytes); + if entry.original.as_ref() != Some(&fingerprint) { + return Err(TransactionError::CorruptJournal { + path: backup, + reason: "backup fingerprint does not match journal".to_owned(), + }); + } + // A crash may leave this derived restore stage behind. Never trust its bytes: + // discard it and regenerate exclusively from the fingerprint-verified backup. + if path_exists(self.filesystem, &rollback_stage)? { + self.filesystem + .remove_file_durable(&rollback_stage) + .map_err(|source| { + io_error("remove stale rollback stage", &rollback_stage, source) + })?; + } + self.filesystem + .write_new_durable( + &rollback_stage, + &bytes, + entry.original_mode.unwrap_or(0o600), + ) + .map_err(|source| io_error("stage rollback", &rollback_stage, source))?; + if let Err(failure) = self.inject(&FailurePoint::PublishRollbackRestore(index)) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + self.filesystem + .replace_durable(&rollback_stage, &target) + .map_err(|source| io_error("restore target", &target, source))?; + } else if path_exists(self.filesystem, &target)? { + self.filesystem + .remove_file_durable(&target) + .map_err(|source| io_error("remove newly-created target", &target, source))?; + } + if let Err(failure) = self.inject(&FailurePoint::RecordRollback(index)) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + journal.state = JournalState::RollingBack { remaining: index }; + self.persist_journal(journal_path, journal)?; + } + if let Err(failure) = self.inject(&FailurePoint::MarkRolledBack) { + return Err(self.interrupted( + journal.transaction_id.clone(), + journal.state.clone(), + failure, + )); + } + journal.state = JournalState::RolledBack; + self.persist_journal(journal_path, journal) + } + + fn assert_recoverable_target( + &self, + target: &Path, + entry: &JournalEntry, + ) -> Result<(), TransactionError> { + let current = self.current_fingerprint(target)?; + if current.as_ref() == entry.original.as_ref() + || current.as_ref() == Some(&entry.replacement) + || (current.is_none() && entry.original.is_none()) + { + Ok(()) + } else { + Err(TransactionError::RecoveryConflict { + path: target.to_path_buf(), + expected_original: entry.original.clone(), + expected_replacement: entry.replacement.clone(), + actual: current, + }) + } + } + + fn verify_journal_tree( + &self, + journal: &Journal, + tree: RecoveryTree, + ) -> Result<(), TransactionError> { + for entry in &journal.entries { + let target = journal.workspace_root.join(&entry.target); + let current = self.current_fingerprint(&target)?; + let expected = match tree { + RecoveryTree::Original => entry.original.as_ref(), + RecoveryTree::Replacement => Some(&entry.replacement), + }; + if current.as_ref() != expected { + return Err(TransactionError::RecoveryConflict { + path: target, + expected_original: entry.original.clone(), + expected_replacement: entry.replacement.clone(), + actual: current, + }); + } + } + Ok(()) + } + + fn current_fingerprint( + &self, + target: &Path, + ) -> Result, TransactionError> { + match self.filesystem.read(target) { + Ok(bytes) => Ok(Some(FileFingerprint::for_bytes(&bytes))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(io_error("read target during recovery", target, source)), + } + } + + fn revalidate( + &self, + transaction: &ValidatedWorkspaceTransaction, + ) -> Result<(), TransactionError> { + let root_metadata = self + .filesystem + .metadata(&transaction.workspace_root) + .map_err(|source| { + io_error( + "inspect workspace root", + &transaction.workspace_root, + source, + ) + })?; + if root_metadata.filesystem != transaction.root_filesystem { + return Err(TransactionError::ExpectedStateChanged { + path: transaction.workspace_root.clone(), + }); + } + for write in &transaction.writes { + let current_parent = self + .filesystem + .canonicalize(&write.canonical_parent) + .map_err(|source| { + io_error( + "canonicalize target parent", + &write.canonical_parent, + source, + ) + })?; + if current_parent != write.canonical_parent + || !current_parent.starts_with(&transaction.workspace_root) + { + return Err(TransactionError::OutsideWorkspace { + path: PathBuf::from(&write.relative_target), + resolved: current_parent, + }); + } + match self.filesystem.symlink_metadata(&write.absolute_target) { + Ok(metadata) if metadata.kind != WorkspaceFileKind::File => { + return Err(TransactionError::UnsupportedFileType { + path: write.absolute_target.clone(), + kind: metadata.kind, + }); + } + Ok(metadata) if metadata.filesystem != transaction.root_filesystem => { + return Err(TransactionError::CrossFilesystem { + path: PathBuf::from(&write.relative_target), + root_filesystem: transaction.root_filesystem, + target_filesystem: metadata.filesystem, + }); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(io_error( + "inspect transaction target", + &write.absolute_target, + source, + )); + } + } + validate_current_target(self.filesystem, write)?; + } + Ok(()) + } + + fn ensure_control_directory( + &self, + root: &Path, + root_filesystem: u64, + control: &Path, + ) -> Result<(), TransactionError> { + match self.filesystem.symlink_metadata(control) { + Ok(metadata) => { + self.validate_control_directory(root, root_filesystem, control, metadata) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + self.filesystem.create_dir_all(control).map_err(|source| { + io_error("create transaction control directory", control, source) + })?; + self.filesystem + .sync_directory(root) + .map_err(|source| io_error("sync workspace root", root, source))?; + let metadata = self + .filesystem + .symlink_metadata(control) + .map_err(|source| { + io_error("inspect transaction control directory", control, source) + })?; + self.validate_control_directory(root, root_filesystem, control, metadata) + } + Err(source) => Err(io_error( + "inspect transaction control directory", + control, + source, + )), + } + } + + fn validate_control_directory( + &self, + root: &Path, + root_filesystem: u64, + control: &Path, + metadata: WorkspaceMetadata, + ) -> Result<(), TransactionError> { + if metadata.kind != WorkspaceFileKind::Directory { + return Err(TransactionError::UnsafeControlPath { + path: control.to_path_buf(), + reason: format!("control path has unsupported type {:?}", metadata.kind), + }); + } + let canonical = self.filesystem.canonicalize(control).map_err(|source| { + io_error( + "canonicalize transaction control directory", + control, + source, + ) + })?; + if canonical != control + || !canonical.starts_with(root) + || metadata.filesystem != root_filesystem + { + return Err(TransactionError::UnsafeControlPath { + path: control.to_path_buf(), + reason: "control directory escapes the root or crosses filesystems".to_owned(), + }); + } + Ok(()) + } + + fn acquire_lock(&self, control: &Path) -> Result, TransactionError> { + let lock_path = control.join(LOCK_FILE); + self.filesystem + .try_lock(&lock_path) + .map_err(|source| TransactionError::WorkspaceBusy { + path: lock_path, + source, + }) + } + + fn ensure_no_pending_transactions(&self, control: &Path) -> Result<(), TransactionError> { + let pending = self + .filesystem + .read_directories(control) + .map_err(|source| io_error("list pending transactions", control, source))?; + if let Some(path) = pending.first() { + return Err(TransactionError::RecoveryRequired { path: path.clone() }); + } + Ok(()) + } + + fn write_initial_journal( + &self, + path: &Path, + journal: &Journal, + ) -> Result<(), TransactionError> { + let bytes = serde_json::to_vec_pretty(journal).map_err(|error| { + TransactionError::CorruptJournal { + path: path.to_path_buf(), + reason: error.to_string(), + } + })?; + self.filesystem + .write_new_durable(path, &bytes, 0o600) + .map_err(|source| io_error("write initial transaction journal", path, source)) + } + + fn persist_journal(&self, path: &Path, journal: &Journal) -> Result<(), TransactionError> { + let bytes = serde_json::to_vec_pretty(journal).map_err(|error| { + TransactionError::CorruptJournal { + path: path.to_path_buf(), + reason: error.to_string(), + } + })?; + let temp = path.with_file_name(JOURNAL_TEMP_FILE); + if path_exists(self.filesystem, &temp)? { + self.filesystem + .remove_file_durable(&temp) + .map_err(|source| io_error("remove stale journal update", &temp, source))?; + } + self.filesystem + .write_new_durable(&temp, &bytes, 0o600) + .map_err(|source| io_error("write transaction journal update", &temp, source))?; + self.filesystem + .replace_durable(&temp, path) + .map_err(|source| io_error("publish transaction journal update", path, source)) + } + + fn read_journal(&self, path: &Path) -> Result { + let bytes = self + .filesystem + .read(path) + .map_err(|source| io_error("read transaction journal", path, source))?; + let journal: Journal = + serde_json::from_slice(&bytes).map_err(|error| TransactionError::CorruptJournal { + path: path.to_path_buf(), + reason: error.to_string(), + })?; + if journal.version != JOURNAL_VERSION { + return Err(TransactionError::CorruptJournal { + path: path.to_path_buf(), + reason: format!("unsupported journal version {}", journal.version), + }); + } + Ok(journal) + } + + fn validate_recovery_journal( + &self, + root: &Path, + transaction_directory: &Path, + journal: &Journal, + ) -> Result<(), TransactionError> { + if journal.workspace_root != root { + return Err(TransactionError::CorruptJournal { + path: transaction_directory.join(JOURNAL_FILE), + reason: "journal workspace root does not match recovery root".to_owned(), + }); + } + if transaction_directory + .file_name() + .and_then(|name| name.to_str()) + != Some(journal.transaction_id.as_str()) + { + return Err(TransactionError::CorruptJournal { + path: transaction_directory.join(JOURNAL_FILE), + reason: "journal transaction ID does not match its directory".to_owned(), + }); + } + let root_filesystem = self + .filesystem + .metadata(root) + .map_err(|source| io_error("inspect recovery root", root, source))? + .filesystem; + for entry in &journal.entries { + let target = Path::new(&entry.target); + validate_relative_target(target)?; + let requested_parent = root.join(target.parent().unwrap_or_else(|| Path::new(""))); + let canonical_parent = + self.filesystem + .canonicalize(&requested_parent) + .map_err(|source| { + io_error( + "canonicalize recovery target parent", + &requested_parent, + source, + ) + })?; + if !canonical_parent.starts_with(root) { + return Err(TransactionError::OutsideWorkspace { + path: target.to_path_buf(), + resolved: canonical_parent, + }); + } + let filesystem = self + .filesystem + .metadata(&canonical_parent) + .map_err(|source| { + io_error("inspect recovery target parent", &canonical_parent, source) + })? + .filesystem; + if filesystem != root_filesystem { + return Err(TransactionError::CrossFilesystem { + path: target.to_path_buf(), + root_filesystem, + target_filesystem: filesystem, + }); + } + } + Ok(()) + } + + fn cleanup_transaction(&self, directory: &Path) -> Result<(), TransactionError> { + self.filesystem + .remove_dir_all_durable(directory) + .map_err(|source| io_error("remove completed transaction", directory, source)) + } + + fn inject(&self, point: &FailurePoint) -> Result<(), InjectedFailure> { + self.failures.check(point).map_or(Ok(()), Err) + } + + fn interrupted( + &self, + transaction_id: String, + state: JournalState, + failure: InjectedFailure, + ) -> TransactionError { + TransactionError::Interrupted { + transaction_id, + state, + simulated_crash: failure == InjectedFailure::Crash, + } + } +} + +fn validate_plan( + plan: WorkspaceTransactionPlan, + filesystem: &dyn WorkspaceFilesystem, +) -> Result { + if plan.writes.is_empty() { + return Err(TransactionError::EmptyPlan); + } + let workspace_root = filesystem + .canonicalize(&plan.workspace_root) + .map_err(|source| io_error("canonicalize workspace root", &plan.workspace_root, source))?; + let root_metadata = filesystem + .metadata(&workspace_root) + .map_err(|source| io_error("inspect workspace root", &workspace_root, source))?; + if root_metadata.kind != WorkspaceFileKind::Directory { + return Err(TransactionError::UnsupportedFileType { + path: workspace_root, + kind: root_metadata.kind, + }); + } + + let mut seen = BTreeSet::new(); + let mut writes = Vec::with_capacity(plan.writes.len()); + for write in plan.writes { + let relative_target = validate_relative_target(&write.target)?; + let parent_relative = write.target.parent().unwrap_or_else(|| Path::new("")); + let requested_parent = workspace_root.join(parent_relative); + let canonical_parent = filesystem + .canonicalize(&requested_parent) + .map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + TransactionError::MissingParent { + path: requested_parent.clone(), + } + } else { + io_error("canonicalize target parent", &requested_parent, source) + } + })?; + if !canonical_parent.starts_with(&workspace_root) { + return Err(TransactionError::OutsideWorkspace { + path: write.target, + resolved: canonical_parent, + }); + } + let parent_metadata = filesystem + .metadata(&canonical_parent) + .map_err(|source| io_error("inspect target parent", &canonical_parent, source))?; + if parent_metadata.kind != WorkspaceFileKind::Directory { + return Err(TransactionError::UnsupportedFileType { + path: canonical_parent, + kind: parent_metadata.kind, + }); + } + if parent_metadata.filesystem != root_metadata.filesystem { + return Err(TransactionError::CrossFilesystem { + path: write.target, + root_filesystem: root_metadata.filesystem, + target_filesystem: parent_metadata.filesystem, + }); + } + let file_name = + write + .target + .file_name() + .ok_or_else(|| TransactionError::InvalidTarget { + path: write.target.clone(), + reason: "target has no file name".to_owned(), + })?; + let absolute_target = canonical_parent.join(file_name); + if !seen.insert(absolute_target.clone()) { + return Err(TransactionError::DuplicateTarget { path: write.target }); + } + + let metadata = match filesystem.symlink_metadata(&absolute_target) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(source) => { + return Err(io_error( + "inspect transaction target", + &absolute_target, + source, + )); + } + }; + let original_mode = match metadata { + Some(metadata) => { + if metadata.kind != WorkspaceFileKind::File { + return Err(TransactionError::UnsupportedFileType { + path: absolute_target, + kind: metadata.kind, + }); + } + if metadata.filesystem != root_metadata.filesystem { + return Err(TransactionError::CrossFilesystem { + path: write.target, + root_filesystem: root_metadata.filesystem, + target_filesystem: metadata.filesystem, + }); + } + Some(metadata.mode) + } + None => None, + }; + + let validated = ValidatedWrite { + relative_target, + canonical_parent, + absolute_target, + expected: write.expected, + replacement: write.replacement, + original_mode, + }; + validate_current_target(filesystem, &validated)?; + writes.push(validated); + } + + Ok(ValidatedWorkspaceTransaction { + workspace_root, + root_filesystem: root_metadata.filesystem, + writes, + }) +} + +fn validate_relative_target(path: &Path) -> Result { + let raw = path + .to_str() + .ok_or_else(|| TransactionError::InvalidTarget { + path: path.to_path_buf(), + reason: "target path must be valid UTF-8 for the durable journal".to_owned(), + })?; + let safe = SafeRelativePath::new(raw).map_err(|error| TransactionError::InvalidTarget { + path: path.to_path_buf(), + reason: error.to_string(), + })?; + if safe.as_str().split('/').next() == Some(CONTROL_DIRECTORY) { + return Err(TransactionError::InvalidTarget { + path: path.to_path_buf(), + reason: "the transaction control directory is reserved".to_owned(), + }); + } + Ok(safe.as_str().to_owned()) +} + +fn validate_current_target( + filesystem: &dyn WorkspaceFilesystem, + write: &ValidatedWrite, +) -> Result<(), TransactionError> { + let current = match filesystem.read(&write.absolute_target) { + Ok(bytes) => ExpectedTarget::Present(FileFingerprint::for_bytes(&bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => ExpectedTarget::Absent, + Err(source) => { + return Err(io_error( + "read transaction target", + &write.absolute_target, + source, + )); + } + }; + if current == write.expected { + Ok(()) + } else { + Err(TransactionError::ExpectedStateMismatch { + path: write.absolute_target.clone(), + expected: write.expected.clone(), + actual: current, + }) + } +} + +fn path_exists( + filesystem: &dyn WorkspaceFilesystem, + path: &Path, +) -> Result { + match filesystem.symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(io_error("inspect path", path, source)), + } +} + +fn transaction_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let sequence = TRANSACTION_SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("txn-{}-{nanos}-{sequence}", std::process::id()) +} + +#[derive(Clone, Copy, Debug)] +enum RecoveryTree { + Original, + Replacement, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Journal { + version: u32, + transaction_id: String, + workspace_root: PathBuf, + state: JournalState, + prepared_replacements: usize, + prepared_backups: usize, + entries: Vec, +} + +impl Journal { + fn new(transaction_id: &str, transaction: &ValidatedWorkspaceTransaction) -> Self { + Self { + version: JOURNAL_VERSION, + transaction_id: transaction_id.to_owned(), + workspace_root: transaction.workspace_root.clone(), + state: JournalState::Preparing, + prepared_replacements: 0, + prepared_backups: 0, + entries: transaction + .writes + .iter() + .map(|write| JournalEntry { + target: write.relative_target.clone(), + original: match &write.expected { + ExpectedTarget::Absent => None, + ExpectedTarget::Present(fingerprint) => Some(fingerprint.clone()), + }, + replacement: FileFingerprint::for_bytes(&write.replacement.bytes), + original_mode: write.original_mode, + replacement_mode: write + .original_mode + .unwrap_or(write.replacement.new_file_mode), + }) + .collect(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", rename_all = "snake_case")] +pub(crate) enum JournalState { + Preparing, + Prepared, + Committing { completed: usize }, + Committed, + RollingBack { remaining: usize }, + RolledBack, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct JournalEntry { + target: String, + original: Option, + replacement: FileFingerprint, + original_mode: Option, + replacement_mode: u32, +} + +#[derive(Debug)] +pub(crate) enum TransactionError { + EmptyPlan, + InvalidReplacement { + message: String, + }, + InvalidTarget { + path: PathBuf, + reason: String, + }, + MissingParent { + path: PathBuf, + }, + OutsideWorkspace { + path: PathBuf, + resolved: PathBuf, + }, + DuplicateTarget { + path: PathBuf, + }, + UnsupportedFileType { + path: PathBuf, + kind: WorkspaceFileKind, + }, + CrossFilesystem { + path: PathBuf, + root_filesystem: u64, + target_filesystem: u64, + }, + ExpectedStateMismatch { + path: PathBuf, + expected: ExpectedTarget, + actual: ExpectedTarget, + }, + ExpectedStateChanged { + path: PathBuf, + }, + WorkspaceBusy { + path: PathBuf, + source: io::Error, + }, + RecoveryRequired { + path: PathBuf, + }, + UnsafeControlPath { + path: PathBuf, + reason: String, + }, + CorruptJournal { + path: PathBuf, + reason: String, + }, + RecoveryConflict { + path: PathBuf, + expected_original: Option, + expected_replacement: FileFingerprint, + actual: Option, + }, + Injected { + point: String, + recoverable_transaction: Option, + }, + Interrupted { + transaction_id: String, + state: JournalState, + simulated_crash: bool, + }, + RolledBack { + transaction_id: String, + cause: Box, + }, + Io { + operation: &'static str, + path: PathBuf, + source: io::Error, + }, +} + +impl fmt::Display for TransactionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPlan => write!(formatter, "workspace transaction plan is empty"), + Self::InvalidReplacement { message } => { + write!(formatter, "replacement bytes failed validation: {message}") + } + Self::InvalidTarget { path, reason } => { + write!( + formatter, + "invalid transaction target {}: {reason}", + path.display() + ) + } + Self::MissingParent { path } => write!( + formatter, + "transaction target parent {} does not exist; parent creation is outside this contract", + path.display() + ), + Self::OutsideWorkspace { path, resolved } => write!( + formatter, + "transaction target {} escapes the workspace root through {}", + path.display(), + resolved.display() + ), + Self::DuplicateTarget { path } => { + write!(formatter, "duplicate transaction target {}", path.display()) + } + Self::UnsupportedFileType { path, kind } => write!( + formatter, + "unsupported transaction target type {kind:?} at {}", + path.display() + ), + Self::CrossFilesystem { + path, + root_filesystem, + target_filesystem, + } => write!( + formatter, + "transaction target {} is on filesystem {target_filesystem}, not workspace filesystem {root_filesystem}", + path.display() + ), + Self::ExpectedStateMismatch { + path, + expected, + actual, + } => write!( + formatter, + "transaction target {} does not match expected state {expected:?}; found {actual:?}", + path.display() + ), + Self::ExpectedStateChanged { path } => write!( + formatter, + "transaction target {} changed after validation", + path.display() + ), + Self::WorkspaceBusy { path, source } => write!( + formatter, + "workspace transaction lock {} is busy: {source}", + path.display() + ), + Self::RecoveryRequired { path } => write!( + formatter, + "workspace has a pending transaction at {}; recover it before committing", + path.display() + ), + Self::UnsafeControlPath { path, reason } => write!( + formatter, + "unsafe workspace transaction control path {}: {reason}", + path.display() + ), + Self::CorruptJournal { path, reason } => write!( + formatter, + "transaction journal or backup {} is corrupt: {reason}", + path.display() + ), + Self::RecoveryConflict { + path, + expected_original, + expected_replacement, + actual, + } => write!( + formatter, + "cannot recover {}: current fingerprint {actual:?} is neither original {expected_original:?} nor replacement {expected_replacement:?}", + path.display() + ), + Self::Injected { + point, + recoverable_transaction, + } => write!( + formatter, + "injected transaction failure at {point}; recoverable transaction: {recoverable_transaction:?}" + ), + Self::Interrupted { + transaction_id, + state, + simulated_crash, + } => write!( + formatter, + "transaction {transaction_id} interrupted in state {state:?} (simulated_crash={simulated_crash}); durable recovery is required" + ), + Self::RolledBack { + transaction_id, + cause, + } => write!( + formatter, + "transaction {transaction_id} failed and restored the original workspace: {cause}" + ), + Self::Io { + operation, + path, + source, + } => write!( + formatter, + "failed to {operation} {}: {source}", + path.display() + ), + } + } +} + +impl std::error::Error for TransactionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::WorkspaceBusy { source, .. } | Self::Io { source, .. } => Some(source), + Self::RolledBack { cause, .. } => Some(cause), + _ => None, + } + } +} + +fn io_error(operation: &'static str, path: &Path, source: io::Error) -> TransactionError { + TransactionError::Io { + operation, + path: path.to_path_buf(), + source, + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Mutex; + use tempfile::TempDir; + + fn replacement(text: &str) -> ValidatedReplacement { + ValidatedReplacement::validate(text.as_bytes().to_vec(), |bytes| { + if bytes.is_empty() { + Err("empty source".to_owned()) + } else { + Ok(()) + } + }) + .unwrap() + } + + fn expected(path: &Path) -> ExpectedTarget { + ExpectedTarget::Present(FileFingerprint::for_bytes(&fs::read(path).unwrap())) + } + + fn fixture() -> (TempDir, WorkspaceTransactionPlan) { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("deck.yaml"), b"old deck\n").unwrap(); + fs::write(directory.path().join("overlay.yaml"), b"old overlay\n").unwrap(); + let plan = WorkspaceTransactionPlan::new( + directory.path(), + vec![ + WorkspaceWrite::new( + "deck.yaml", + expected(&directory.path().join("deck.yaml")), + replacement("new deck\n"), + ), + WorkspaceWrite::new( + "overlay.yaml", + expected(&directory.path().join("overlay.yaml")), + replacement("new overlay\n"), + ), + WorkspaceWrite::new("created.yaml", ExpectedTarget::Absent, replacement("new\n")), + ], + ); + (directory, plan) + } + + fn assert_original(root: &Path) { + assert_eq!(fs::read(root.join("deck.yaml")).unwrap(), b"old deck\n"); + assert_eq!( + fs::read(root.join("overlay.yaml")).unwrap(), + b"old overlay\n" + ); + assert!(!root.join("created.yaml").exists()); + } + + fn assert_replaced(root: &Path) { + assert_eq!(fs::read(root.join("deck.yaml")).unwrap(), b"new deck\n"); + assert_eq!( + fs::read(root.join("overlay.yaml")).unwrap(), + b"new overlay\n" + ); + assert_eq!(fs::read(root.join("created.yaml")).unwrap(), b"new\n"); + } + + #[derive(Debug)] + struct FailOnce { + point: FailurePoint, + kind: InjectedFailure, + fired: Mutex, + } + + impl FailOnce { + fn new(point: FailurePoint, kind: InjectedFailure) -> Self { + Self { + point, + kind, + fired: Mutex::new(false), + } + } + } + + impl FailureInjector for FailOnce { + fn check(&self, point: &FailurePoint) -> Option { + let mut fired = self.fired.lock().unwrap(); + if !*fired && point == &self.point { + *fired = true; + Some(self.kind) + } else { + None + } + } + } + + #[test] + fn validates_replacements_expected_fingerprints_and_commits_complete_plan() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap(); + assert_replaced(directory.path()); + assert_eq!( + fs::read_dir(directory.path().join(CONTROL_DIRECTORY)) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().unwrap().is_dir()) + .count(), + 0 + ); + } + + #[test] + fn replacement_validation_is_required_before_a_plan_can_be_built() { + let error = ValidatedReplacement::validate(Vec::new(), |_| Err("not canonical".to_owned())) + .unwrap_err(); + assert!(matches!(error, TransactionError::InvalidReplacement { .. })); + } + + #[test] + fn validation_rejects_duplicate_external_parent_and_symlink_escape_without_mutation() { + let filesystem = RealWorkspaceFilesystem; + let directory = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("deck.yaml"), b"old\n").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.path(), directory.path().join("escape")).unwrap(); + + let duplicate = WorkspaceTransactionPlan::new( + directory.path(), + vec![ + WorkspaceWrite::new( + "deck.yaml", + expected(&directory.path().join("deck.yaml")), + replacement("a"), + ), + WorkspaceWrite::new( + "deck.yaml", + expected(&directory.path().join("deck.yaml")), + replacement("b"), + ), + ], + ); + assert!(matches!( + duplicate.validate(&filesystem).unwrap_err(), + TransactionError::DuplicateTarget { .. } + )); + + let absolute = WorkspaceTransactionPlan::new( + directory.path(), + vec![WorkspaceWrite::new( + outside.path().join("outside.yaml"), + ExpectedTarget::Absent, + replacement("outside"), + )], + ); + assert!(matches!( + absolute.validate(&filesystem).unwrap_err(), + TransactionError::InvalidTarget { .. } + )); + + #[cfg(unix)] + { + let escape = WorkspaceTransactionPlan::new( + directory.path(), + vec![WorkspaceWrite::new( + "escape/outside.yaml", + ExpectedTarget::Absent, + replacement("outside"), + )], + ); + assert!(matches!( + escape.validate(&filesystem).unwrap_err(), + TransactionError::OutsideWorkspace { .. } + )); + } + assert_eq!( + fs::read(directory.path().join("deck.yaml")).unwrap(), + b"old\n" + ); + assert!(!outside.path().join("outside.yaml").exists()); + assert!(!directory.path().join(CONTROL_DIRECTORY).exists()); + } + + #[test] + fn transaction_targets_use_canonical_portable_relative_syntax() { + for raw in [ + "", + ".", + "./deck.yaml", + "../deck.yaml", + "nested/../deck.yaml", + "/tmp/deck.yaml", + "C:/deck.yaml", + r"\\server\share\deck.yaml", + r"nested\deck.yaml", + "nested//deck.yaml", + ] { + let error = validate_relative_target(Path::new(raw)).unwrap_err(); + assert!( + matches!(error, TransactionError::InvalidTarget { .. }), + "{raw:?}" + ); + } + assert_eq!( + validate_relative_target(Path::new("nested/deck.yaml")).unwrap(), + "nested/deck.yaml" + ); + } + + #[test] + fn validation_rejects_stale_expected_state_and_unsupported_target_types() { + let filesystem = RealWorkspaceFilesystem; + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("deck.yaml"), b"current\n").unwrap(); + fs::create_dir(directory.path().join("target-directory")).unwrap(); + let stale = WorkspaceTransactionPlan::new( + directory.path(), + vec![WorkspaceWrite::new( + "deck.yaml", + ExpectedTarget::Present(FileFingerprint::for_bytes(b"stale\n")), + replacement("next"), + )], + ); + assert!(matches!( + stale.validate(&filesystem).unwrap_err(), + TransactionError::ExpectedStateMismatch { .. } + )); + let directory_target = WorkspaceTransactionPlan::new( + directory.path(), + vec![WorkspaceWrite::new( + "target-directory", + ExpectedTarget::Absent, + replacement("next"), + )], + ); + assert!(matches!( + directory_target.validate(&filesystem).unwrap_err(), + TransactionError::UnsupportedFileType { .. } + )); + } + + struct DeviceOverrideFilesystem { + real: RealWorkspaceFilesystem, + overrides: BTreeMap, + create_at_publish: Option<(PathBuf, Vec)>, + } + + impl WorkspaceFilesystem for DeviceOverrideFilesystem { + fn canonicalize(&self, path: &Path) -> io::Result { + self.real.canonicalize(path) + } + fn metadata(&self, path: &Path) -> io::Result { + let mut metadata = self.real.metadata(path)?; + if let Some(device) = self.overrides.get(path) { + metadata.filesystem = *device; + } + Ok(metadata) + } + fn symlink_metadata(&self, path: &Path) -> io::Result { + self.real.symlink_metadata(path) + } + fn read(&self, path: &Path) -> io::Result> { + self.real.read(path) + } + fn read_directories(&self, path: &Path) -> io::Result> { + self.real.read_directories(path) + } + fn create_dir_all(&self, path: &Path) -> io::Result<()> { + self.real.create_dir_all(path) + } + fn write_new_durable(&self, path: &Path, bytes: &[u8], mode: u32) -> io::Result<()> { + self.real.write_new_durable(path, bytes, mode) + } + fn replace_durable(&self, source: &Path, target: &Path) -> io::Result<()> { + self.real.replace_durable(source, target) + } + fn publish_new_durable(&self, source: &Path, target: &Path) -> io::Result<()> { + if let Some((race_target, bytes)) = &self.create_at_publish + && race_target == target + { + fs::write(target, bytes)?; + } + self.real.publish_new_durable(source, target) + } + fn remove_file_durable(&self, path: &Path) -> io::Result<()> { + self.real.remove_file_durable(path) + } + fn remove_dir_all_durable(&self, path: &Path) -> io::Result<()> { + self.real.remove_dir_all_durable(path) + } + fn sync_directory(&self, path: &Path) -> io::Result<()> { + self.real.sync_directory(path) + } + fn try_lock(&self, path: &Path) -> io::Result> { + self.real.try_lock(path) + } + } + + #[test] + fn absent_target_created_after_validation_returns_typed_expected_state_conflict() { + let directory = tempfile::tempdir().unwrap(); + let filesystem = RealWorkspaceFilesystem; + let target = directory.path().join("new.yaml"); + let plan = WorkspaceTransactionPlan::new( + directory.path(), + vec![WorkspaceWrite::new( + "new.yaml", + ExpectedTarget::Absent, + replacement("transaction replacement\n"), + )], + ); + let transaction = plan.validate(&filesystem).unwrap(); + fs::write(&target, b"external creation\n").unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap_err(); + assert!(matches!( + error, + TransactionError::ExpectedStateMismatch { + expected: ExpectedTarget::Absent, + actual: ExpectedTarget::Present(_), + .. + } + )); + assert_eq!(fs::read(target).unwrap(), b"external creation\n"); + } + + #[test] + fn absent_target_created_at_publish_is_never_overwritten() { + let directory = tempfile::tempdir().unwrap(); + let canonical_root = fs::canonicalize(directory.path()).unwrap(); + let target = canonical_root.join("new.yaml"); + let filesystem = DeviceOverrideFilesystem { + real: RealWorkspaceFilesystem, + overrides: BTreeMap::new(), + create_at_publish: Some((target.clone(), b"external creation\n".to_vec())), + }; + let plan = WorkspaceTransactionPlan::new( + &canonical_root, + vec![WorkspaceWrite::new( + "new.yaml", + ExpectedTarget::Absent, + replacement("transaction replacement\n"), + )], + ); + let transaction = plan.validate(&filesystem).unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap_err(); + assert!(matches!(error, TransactionError::RecoveryConflict { .. })); + assert_eq!(fs::read(target).unwrap(), b"external creation\n"); + } + + #[test] + fn injected_filesystem_identity_rejects_cross_filesystem_plan() { + let (directory, plan) = fixture(); + let canonical_root = fs::canonicalize(directory.path()).unwrap(); + let root_device = RealWorkspaceFilesystem + .metadata(&canonical_root) + .unwrap() + .filesystem; + let filesystem = DeviceOverrideFilesystem { + real: RealWorkspaceFilesystem, + overrides: BTreeMap::from([(canonical_root.clone(), root_device + 1)]), + create_at_publish: None, + }; + assert!(matches!( + plan.validate(&filesystem).unwrap_err(), + TransactionError::CrossFilesystem { .. } + )); + } + + #[test] + fn all_prepare_and_backup_crashes_leave_original_tree_and_recoverable_journal() { + let points = [ + FailurePoint::CreateJournal, + FailurePoint::StageReplacement(0), + FailurePoint::StageReplacement(1), + FailurePoint::StageReplacement(2), + FailurePoint::Backup(0), + FailurePoint::Backup(1), + FailurePoint::MarkPrepared, + ]; + for point in points { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(point.clone(), InjectedFailure::Crash); + let error = WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + assert!( + matches!(error, TransactionError::Interrupted { .. }), + "{point:?}: {error}" + ); + assert_original(directory.path()); + let actions = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap(); + assert_eq!(actions.len(), 1, "{point:?}"); + assert_original(directory.path()); + } + } + + #[test] + fn crashes_at_every_replace_and_commit_record_are_rolled_back_on_restart() { + let points = [ + FailurePoint::Replace(0), + FailurePoint::RecordCommit(0), + FailurePoint::Replace(1), + FailurePoint::RecordCommit(1), + FailurePoint::Replace(2), + FailurePoint::RecordCommit(2), + FailurePoint::MarkCommitted, + ]; + for point in points { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(point.clone(), InjectedFailure::Crash); + WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + let actions = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap(); + assert_eq!(actions.len(), 1, "{point:?}"); + assert_original(directory.path()); + } + } + + #[test] + fn commit_rechecks_expected_state_after_validation() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + fs::write(directory.path().join("deck.yaml"), b"external edit\n").unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap_err(); + assert!(matches!( + error, + TransactionError::ExpectedStateMismatch { .. } + )); + assert_eq!( + fs::read(directory.path().join("deck.yaml")).unwrap(), + b"external edit\n" + ); + assert_eq!( + fs::read(directory.path().join("overlay.yaml")).unwrap(), + b"old overlay\n" + ); + } + + #[test] + fn cooperative_workspace_lock_rejects_a_concurrent_writer() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let control = directory.path().join(CONTROL_DIRECTORY); + fs::create_dir(&control).unwrap(); + let _held_lock = filesystem.try_lock(&control.join(LOCK_FILE)).unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap_err(); + assert!(matches!(error, TransactionError::WorkspaceBusy { .. })); + assert_original(directory.path()); + } + + #[test] + fn ordinary_replace_failure_rolls_back_before_returning() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(FailurePoint::Replace(1), InjectedFailure::Error); + let error = WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + assert!( + matches!(error, TransactionError::RolledBack { .. }), + "{error}" + ); + assert_original(directory.path()); + } + + #[test] + fn rollback_failures_leave_an_explicit_journal_and_recovery_resumes_idempotently() { + let points = [ + FailurePoint::Rollback(2), + FailurePoint::RecordRollback(2), + FailurePoint::Rollback(1), + FailurePoint::PublishRollbackRestore(1), + FailurePoint::RecordRollback(1), + FailurePoint::Rollback(0), + FailurePoint::PublishRollbackRestore(0), + FailurePoint::RecordRollback(0), + FailurePoint::MarkRolledBack, + ]; + for rollback_point in points { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failures = TwoFailures::new( + (FailurePoint::Replace(2), InjectedFailure::Error), + (rollback_point.clone(), InjectedFailure::Crash), + ); + WorkspaceTransactionManager::new(&filesystem, &failures) + .commit(transaction) + .unwrap_err(); + WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap(); + assert_original(directory.path()); + } + } + + #[test] + fn recovery_discards_a_stale_restore_stage_and_regenerates_it_from_the_backup() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failures = TwoFailures::new( + (FailurePoint::Replace(2), InjectedFailure::Error), + ( + FailurePoint::PublishRollbackRestore(1), + InjectedFailure::Crash, + ), + ); + WorkspaceTransactionManager::new(&filesystem, &failures) + .commit(transaction) + .unwrap_err(); + + let control = directory.path().join(CONTROL_DIRECTORY); + let transaction_directory = fs::read_dir(&control) + .unwrap() + .filter_map(Result::ok) + .find(|entry| entry.file_type().unwrap().is_dir()) + .unwrap() + .path(); + let stale_stage = transaction_directory.join("backups/1.restore"); + assert!(stale_stage.exists()); + fs::write(&stale_stage, b"attacker-controlled stale stage\n").unwrap(); + + let actions = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap(); + assert!(matches!( + actions.as_slice(), + [RecoveryAction::RolledBack { .. }] + )); + assert_original(directory.path()); + assert!(!transaction_directory.exists()); + + assert!( + WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap() + .is_empty() + ); + assert_original(directory.path()); + } + + struct TwoFailures { + failures: Mutex>, + } + + impl TwoFailures { + fn new( + first: (FailurePoint, InjectedFailure), + second: (FailurePoint, InjectedFailure), + ) -> Self { + Self { + failures: Mutex::new(vec![first, second]), + } + } + } + + impl FailureInjector for TwoFailures { + fn check(&self, point: &FailurePoint) -> Option { + let mut failures = self.failures.lock().unwrap(); + if failures + .first() + .is_some_and(|(expected, _)| expected == point) + { + Some(failures.remove(0).1) + } else { + None + } + } + } + + #[test] + fn committed_journal_is_deterministically_finalized_after_cleanup_crash() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(FailurePoint::FinalizeCleanup, InjectedFailure::Crash); + WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + assert_replaced(directory.path()); + let actions = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap(); + assert!(matches!( + actions.as_slice(), + [RecoveryAction::FinalizedCommit { .. }] + )); + assert_replaced(directory.path()); + } + + #[test] + fn committed_recovery_refuses_to_accept_unrecognized_mixed_content() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(FailurePoint::FinalizeCleanup, InjectedFailure::Crash); + WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + fs::write(directory.path().join("overlay.yaml"), b"third party\n").unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap_err(); + assert!(matches!(error, TransactionError::RecoveryConflict { .. })); + assert_eq!( + fs::read(directory.path().join("overlay.yaml")).unwrap(), + b"third party\n" + ); + } + + #[test] + fn recovery_refuses_to_overwrite_unrecognized_concurrent_content() { + let (directory, plan) = fixture(); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + let failure = FailOnce::new(FailurePoint::RecordCommit(0), InjectedFailure::Crash); + WorkspaceTransactionManager::new(&filesystem, &failure) + .commit(transaction) + .unwrap_err(); + fs::write(directory.path().join("deck.yaml"), b"third party\n").unwrap(); + let error = WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .recover(directory.path()) + .unwrap_err(); + assert!(matches!(error, TransactionError::RecoveryConflict { .. })); + assert_eq!( + fs::read(directory.path().join("deck.yaml")).unwrap(), + b"third party\n" + ); + assert!(directory.path().join(CONTROL_DIRECTORY).exists()); + } + + #[test] + fn existing_permissions_are_preserved_and_new_mode_is_explicit() { + use std::os::unix::fs::PermissionsExt; + let directory = tempfile::tempdir().unwrap(); + let existing = directory.path().join("script.sh"); + fs::write(&existing, b"old\n").unwrap(); + fs::set_permissions(&existing, fs::Permissions::from_mode(0o751)).unwrap(); + let new_replacement = replacement("new file\n").with_new_file_mode(0o640).unwrap(); + let plan = WorkspaceTransactionPlan::new( + directory.path(), + vec![ + WorkspaceWrite::new("script.sh", expected(&existing), replacement("new\n")), + WorkspaceWrite::new("new.yaml", ExpectedTarget::Absent, new_replacement), + ], + ); + let filesystem = RealWorkspaceFilesystem; + let transaction = plan.validate(&filesystem).unwrap(); + WorkspaceTransactionManager::new(&filesystem, &NoFailures) + .commit(transaction) + .unwrap(); + assert_eq!( + fs::metadata(existing).unwrap().permissions().mode() & 0o7777, + 0o751 + ); + assert_eq!( + fs::metadata(directory.path().join("new.yaml")) + .unwrap() + .permissions() + .mode() + & 0o7777, + 0o640 + ); + } +} diff --git a/crates/brain-brew-cli/tests/cli.rs b/crates/brain-brew-cli/tests/cli.rs new file mode 100644 index 0000000..9947185 --- /dev/null +++ b/crates/brain-brew-cli/tests/cli.rs @@ -0,0 +1,8356 @@ +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdout, Command, Stdio}; +#[cfg(feature = "workbench-write-dev")] +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use brain_brew_formats::canonical_yaml; +use flate2::Compression; +use flate2::write::GzEncoder; +use tar::Builder; + +#[test] +fn top_level_help_includes_examples() { + let output = run(["--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Usage:")); + assert!(out.contains("Examples:")); + assert!(out.contains("brainbrew targets --manifest brainbrew.yaml")); + assert!( + out.contains("brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended") + ); +} + +#[test] +fn command_help_includes_focused_examples() { + let output = run(["compose", "--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Usage:")); + assert!(out.contains( + "brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml" + )); +} + +#[test] +fn workbench_help_includes_serve_entrypoint() { + let top = run(["--help"]); + assert!(top.status.success(), "stderr: {}", stderr(&top)); + assert!(stdout(&top).contains("workbench")); + + let command = run(["workbench", "--help"]); + assert!(command.status.success(), "stderr: {}", stderr(&command)); + let out = stdout(&command); + assert!(out.contains("brainbrew workbench serve --manifest brainbrew.yaml")); + assert!(out.contains("--port")); + assert!(out.contains("--no-open")); + assert!(out.contains("--dev-assets")); + assert!(out.contains("target/workbench-ui")); + assert!(out.contains("workbench-write-dev")); + assert!(out.contains("--enable-write")); + assert!(out.contains("read-only")); + + let serve = run(["workbench", "serve", "--help"]); + assert!(serve.status.success(), "stderr: {}", stderr(&serve)); + assert!(stdout(&serve).contains("brainbrew workbench serve --manifest brainbrew.yaml")); +} + +#[test] +fn workbench_serve_exposes_workspace_metadata_api() { + let dir = temp_dir("workbench-api"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let health = get_json(&server.url("/api/health")); + assert_eq!(health["status"], "ok"); + assert_eq!( + health["manifest"], + dir.join("brainbrew.yaml").display().to_string() + ); + + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["write_capability"]["enabled"], + cfg!(feature = "workbench-write-dev") + ); + assert_eq!( + workspace["write_capability"]["mode"], + if cfg!(feature = "workbench-write-dev") { + "development_write" + } else { + "read_only" + } + ); + assert_eq!( + workspace["manifest"], + dir.join("brainbrew.yaml").display().to_string() + ); + assert_eq!(workspace["languages"]["en"]["display_name"], "English"); + assert_eq!(workspace["languages"]["en"]["source"], true); + assert_eq!( + workspace["languages"]["da"]["translation_overlays"]["base"], + "overlay.translation.da" + ); + assert_eq!( + workspace["languages"]["da"]["targets"]["standard"], + "da-standard" + ); + assert_eq!( + workspace["target_labels"]["da-standard"][0]["language"], + "da" + ); + assert_eq!( + workspace["target_labels"]["da-standard"][0]["label"], + "standard" + ); + assert_eq!( + workspace["translation_profile"]["structural_fields"][0], + "field.flag" + ); + let fingerprints = workspace["fingerprints"].as_array().unwrap(); + assert!( + fingerprints + .iter() + .any(|entry| entry["path"] == "brainbrew.yaml") + ); + assert!( + fingerprints + .iter() + .any(|entry| entry["path"] == "deck.yaml") + ); + assert!(fingerprints.iter().any(|entry| entry["path"] == "da.yaml")); + assert!( + fingerprints + .iter() + .all(|entry| entry["sha256"].as_str().unwrap().len() == 64) + ); + let original_da_fingerprint = fingerprints + .iter() + .find(|entry| entry["path"] == "da.yaml") + .unwrap()["sha256"] + .as_str() + .unwrap() + .to_owned(); + + fs::write( + dir.join("da.yaml"), + r#"id: overlay.translation.da +kind: translation +translations: + direct: + Finland: Suomi +"#, + ) + .unwrap(); + let updated = get_json(&server.url("/api/workspace")); + let updated_da_fingerprint = updated["fingerprints"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["path"] == "da.yaml") + .unwrap()["sha256"] + .as_str() + .unwrap(); + assert_ne!(updated_da_fingerprint, original_da_fingerprint); +} + +#[test] +fn workbench_read_only_server_rejects_every_state_changing_route_without_writes() { + let dir = temp_dir("workbench-read-only-routes"); + write_workbench_workspace(&dir); + let manifest_before = fs::read(dir.join("brainbrew.yaml")).unwrap(); + let deck_before = fs::read(dir.join("deck.yaml")).unwrap(); + let overlay_before = fs::read(dir.join("da.yaml")).unwrap(); + let server = spawn_read_only_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[ + ("BRAINBREW_TRANSACTION_FAIL_POINT", "stage:0"), + ("BRAINBREW_TRANSACTION_SLEEP_BEFORE_REPLACE_MS", "1"), + ], + ); + + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!(workspace["write_capability"]["enabled"], false); + assert_eq!(workspace["write_capability"]["mode"], "read_only"); + assert_eq!(workspace["write_capability"]["runtime_opt_in"], false); + + let preview = get_json( + &server + .url("/api/workbench/new-language-preview?template=da&code=nb&display_name=Norwegian"), + ); + assert_eq!(preview["validation"]["ok"], true); + let (status, body) = post_json_error( + &server.url("/api/workbench/new-language"), + preview["draft"].clone(), + ); + assert_eq!(status, 403); + assert!(body.contains("Workbench is read-only")); + let error: serde_json::Value = serde_json::from_str(&body).expect("Workbench error is JSON"); + assert_eq!(error["error"]["schema_version"], 1); + assert_eq!(error["error"]["code"], "workbench_read_only"); + assert_eq!(error["error"]["category"], "authorization"); + assert!(error["error"]["diagnostics"].is_array()); + + let apply_request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "direct" + }] + }); + let apply_preview = post_json( + &server.url("/api/workbench/apply-preview"), + apply_request.clone(), + ); + assert_eq!(apply_preview["validation"]["ok"], true); + let (status, body) = post_json_error(&server.url("/api/workbench/apply"), apply_request); + assert_eq!(status, 403); + assert!(body.contains("Workbench is read-only")); + + assert_eq!( + fs::read(dir.join("brainbrew.yaml")).unwrap(), + manifest_before + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), deck_before); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), overlay_before); + assert!(!dir.join("overlays/languages/nb.yaml").exists()); +} + +#[cfg(not(feature = "workbench-write-dev"))] +#[test] +fn workbench_runtime_flag_cannot_bypass_missing_compile_capability() { + let dir = temp_dir("workbench-no-compile-bypass"); + write_workbench_workspace(&dir); + let output = run([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--no-open", + "--enable-write", + ]); + + assert!(!output.status.success()); + assert!( + stderr(&output) + .contains("built without the development-only workbench-write-dev capability") + ); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_new_language_scaffold_preview_write_and_initial_edit() { + let dir = temp_dir("workbench-new-language"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let preview = get_json(&server.url( + "/api/workbench/new-language-preview?template=da&code=nb&display_name=Norwegian%20Bokmal", + )); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["language"]["code"], "nb"); + assert_eq!(preview["groups"][0]["label"], "base"); + assert_eq!(preview["groups"][0]["selected"], true); + assert_eq!(preview["groups"][0]["overlay_id"], "overlay.translation.nb"); + assert_eq!(preview["groups"][0]["file"], "overlays/languages/nb.yaml"); + assert_eq!(preview["targets"][0]["target_id"], "nb-standard"); + assert!( + !dir.join("overlays/languages/nb.yaml").exists(), + "preview must not write overlay files" + ); + assert!( + !fs::read_to_string(dir.join("brainbrew.yaml")) + .unwrap() + .contains("nb-standard") + ); + + let created = post_json( + &server.url("/api/workbench/new-language"), + preview["draft"].clone(), + ); + assert_eq!(created["created"], true); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + assert!(manifest.contains("overlay.translation.nb:")); + assert!(manifest.contains("file: overlays/languages/nb.yaml")); + assert!(manifest.contains("nb-standard:")); + assert!(manifest.contains("nb:\n display_name: Norwegian Bokmal")); + let overlay = fs::read_to_string(dir.join("overlays/languages/nb.yaml")).unwrap(); + assert_eq!( + overlay, + "id: overlay.translation.nb\nkind: translation\ntranslations: {}\n" + ); + + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["languages"]["nb"]["display_name"], + "Norwegian Bokmal" + ); + assert_eq!( + workspace["languages"]["nb"]["translation_overlays"]["base"], + "overlay.translation.nb" + ); + assert!( + workspace["fingerprints"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "overlays/languages/nb.yaml") + ); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=nb&target=standard")); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 0); + assert_eq!(pivot["progress"]["missing"], 2); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "nb", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("overlays/languages/nb.yaml")).unwrap(); + assert!(overlay.contains("Helsinki: Helsingfors")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_new_language_scaffold_can_deselect_template_overlay_groups() { + let dir = temp_dir("workbench-new-language-groups"); + fs::write(dir.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + dir.join("da.yaml"), + r#"id: overlay.translation.da +kind: translation +translations: + direct: + Finland: Finland +"#, + ) + .unwrap(); + fs::write( + dir.join("hardcore-da.yaml"), + r#"id: overlay.translation.hardcore.da +kind: translation +translations: + direct: + Helsinki: Helsingfors +"#, + ) + .unwrap(); + fs::write( + dir.join("brainbrew.yaml"), + r#"base: deck.yaml +overlays: + overlay.translation.da: + file: da.yaml + kind: translation + overlay.translation.hardcore.da: + file: hardcore-da.yaml + kind: translation +targets: + da-standard: + overlays: + - overlay.translation.da + - overlay.translation.hardcore.da + en-standard: + overlays: [] +languages: + da: + display_name: Danish + translation_overlays: + base: overlay.translation.da + hardcore: overlay.translation.hardcore.da + primary_target: standard + targets: + standard: da-standard + en: + display_name: English + source: true + primary_target: standard + targets: + standard: en-standard +translation_profile: + structural_fields: + - field.flag +"#, + ) + .unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let preview = get_json( + &server + .url("/api/workbench/new-language-preview?template=da&code=nb&display_name=Norwegian"), + ); + let groups = preview["groups"].as_array().unwrap(); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|group| group["selected"] == true)); + assert_eq!(groups[1]["label"], "hardcore"); + assert_eq!(groups[1]["overlay_id"], "overlay.translation.hardcore.nb"); + assert_eq!(groups[1]["file"], "overlays/languages/hardcore/nb.yaml"); + + let mut draft = preview["draft"].clone(); + draft["groups"][1]["selected"] = serde_json::json!(false); + let created = post_json(&server.url("/api/workbench/new-language"), draft); + assert_eq!(created["created"], true); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + assert!(manifest.contains("overlay.translation.nb:")); + assert!(!manifest.contains("overlay.translation.hardcore.nb")); + assert!(dir.join("overlays/languages/nb.yaml").exists()); + assert!(!dir.join("overlays/languages/hardcore/nb.yaml").exists()); + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["languages"]["nb"]["translation_overlays"]["base"], + "overlay.translation.nb" + ); + assert!(workspace["languages"]["nb"]["translation_overlays"]["hardcore"].is_null()); +} + +#[test] +fn workbench_serve_uses_available_port_and_embedded_assets_by_default() { + let dir = temp_dir("workbench-default-assets"); + write_workbench_workspace(&dir); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--no-open", + ]); + + let response = ureq::get(&server.url("/")).call().expect("GET / succeeds"); + assert_eq!(response.status(), 200); + assert!( + response + .header("content-type") + .unwrap_or_default() + .starts_with("text/html") + ); + let index = response.into_string().unwrap(); + assert!(index.contains("Brain Brew Deck Workbench")); + assert!(index.contains("TrunkApplicationStarted")); + + let js_path = quoted_path_containing(&index, ".js"); + let js_response = ureq::get(&server.url(&js_path)) + .call() + .expect("GET embedded JS succeeds"); + assert_eq!(js_response.status(), 200); + assert!( + js_response + .header("content-type") + .unwrap_or_default() + .contains("javascript") + ); + assert!(js_response.into_string().unwrap().contains("wasm_bindgen")); + + let wasm_path = quoted_path_containing(&index, ".wasm"); + let wasm_response = ureq::get(&server.url(&wasm_path)) + .call() + .expect("GET embedded WASM succeeds"); + assert_eq!(wasm_response.status(), 200); + assert_eq!( + wasm_response.header("content-type").unwrap_or_default(), + "application/wasm" + ); + let mut wasm_bytes = Vec::new(); + wasm_response + .into_reader() + .read_to_end(&mut wasm_bytes) + .unwrap(); + assert!(wasm_bytes.starts_with(b"\0asm")); +} + +#[test] +fn workbench_navigation_lists_are_paginated_and_compact() { + let dir = temp_dir("workbench-navigation-lists"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let notes = get_json( + &server.url("/api/workbench/note-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(notes["language"]["code"], "da"); + assert_eq!(notes["target"]["label"], "standard"); + assert_eq!(notes["total"], 2); + assert_eq!(notes["limit"], 1); + assert_eq!(notes["offset"], 0); + assert_eq!(notes["has_more"], true); + assert_eq!(notes["progress"]["total"], 4); + assert_eq!(notes["rows"].as_array().unwrap().len(), 1); + assert!( + notes["rows"][0]["note_id"] + .as_str() + .unwrap() + .starts_with("note.") + ); + assert!(notes["rows"][0].get("fields").is_none()); + assert!(notes["rows"][0].get("source_preview").is_none()); + + let detail = get_json(&server.url(&format!( + "/api/workbench/note-detail?language=da&target=standard¬e={}", + notes["rows"][0]["note_id"].as_str().unwrap() + ))); + assert_eq!(detail["notes"].as_array().unwrap().len(), 1); + assert_eq!(detail["notes"][0]["note_id"], notes["rows"][0]["note_id"]); + assert!(detail["notes"][0]["fields"].as_array().unwrap().len() >= 2); + + let notes_page_2 = get_json( + &server.url("/api/workbench/note-list?language=da&target=standard&limit=1&offset=1"), + ); + assert_eq!(notes_page_2["total"], 2); + assert_eq!(notes_page_2["rows"].as_array().unwrap().len(), 1); + assert_eq!(notes_page_2["has_more"], false); + assert_ne!( + notes["rows"][0]["note_id"], + notes_page_2["rows"][0]["note_id"] + ); + + let cards = get_json( + &server.url("/api/workbench/card-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(cards["total"], 2); + assert_eq!(cards["progress"]["total"], 2); + assert_eq!(cards["rows"].as_array().unwrap().len(), 1); + assert!(cards["rows"][0]["card_id"].as_str().unwrap().contains("::")); + assert!(cards["rows"][0].get("fields").is_none()); + assert!(cards["rows"][0].get("source_preview").is_none()); + + let strings = get_json( + &server + .url("/api/workbench/source-string-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(strings["total"], 3); + assert_eq!(strings["progress"]["total"], 3); + assert_eq!(strings["rows"].as_array().unwrap().len(), 1); + assert!(!strings["rows"][0]["source"].as_str().unwrap().is_empty()); + assert!(strings["rows"][0].get("occurrences").is_none()); + + let full_pivot = get_json(&server.url("/api/workbench/card-pivot?language=da&target=standard")); + assert_eq!(full_pivot["cards"].as_array().unwrap().len(), 2); + assert!(full_pivot["selected_card"].get("fields").is_some()); +} + +#[test] +fn workbench_navigation_titles_follow_note_type_field_order() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let notes = get_json( + &server.url("/api/workbench/note-list?language=de&target=standard&limit=8&offset=0"), + ); + let titles = notes["rows"] + .as_array() + .unwrap() + .iter() + .map(|row| row["title"].as_str().unwrap_or_default().to_owned()) + .collect::>(); + assert!(titles.iter().all(|title| !title.trim().is_empty())); + assert_eq!(titles.first().map(String::as_str), Some("Abkhazia")); + assert!(!titles.iter().any(|title| title == "Sukhumi")); +} + +#[test] +fn workbench_optional_metadata_list_is_paginated_and_keeps_full_totals() { + let dir = temp_dir("workbench-optional-metadata-list"); + write_workbench_optional_metadata_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let optional = + get_json(&server.url("/api/workbench/metadata-list?language=da&limit=1&offset=0")); + assert_eq!(optional["language"]["code"], "da"); + assert_eq!(optional["limit"], 1); + assert_eq!(optional["offset"], 0); + assert!(optional["total"].as_u64().unwrap() > 1); + assert_eq!(optional["has_more"], true); + assert_eq!(optional["rows"].as_array().unwrap().len(), 1); + assert_eq!(optional["main_progress"]["total"], 2); + assert_eq!(optional["metadata_progress"]["total"], optional["total"]); + assert!(optional["rows"][0]["path"].as_str().unwrap().contains('.')); + assert!(optional["rows"][0].get("target").is_none()); +} + +#[test] +fn workbench_navigation_lists_reject_invalid_pagination() { + let dir = temp_dir("workbench-navigation-list-errors"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + assert_get_status_contains( + &server.url("/api/workbench/note-list?limit=0"), + 400, + "invalid pagination limit", + ); + assert_get_status_contains( + &server.url("/api/workbench/source-string-list?offset=-1"), + 400, + "invalid pagination offset", + ); + assert_get_status_contains( + &server.url("/api/workbench/card-list?limit=9999"), + 400, + "invalid pagination limit", + ); + let response = ureq::get(&server.url("/api/workbench/note-list?limit=0")) + .call() + .unwrap_err() + .into_response() + .unwrap(); + assert_eq!( + response.header("content-type").unwrap_or_default(), + "application/json" + ); + let body: serde_json::Value = serde_json::from_str(&response.into_string().unwrap()).unwrap(); + assert_eq!(body["error"]["schema_version"], 1); + assert_eq!(body["error"]["code"], "invalid_request"); + assert_eq!(body["error"]["category"], "request"); + assert!(body["error"]["diagnostics"].as_array().unwrap().is_empty()); +} + +#[test] +fn workbench_domain_conflict_uses_versioned_typed_http_envelope() { + let dir = temp_dir("workbench-domain-conflict"); + write_workbench_workspace(&dir); + fs::write( + dir.join("da.yaml"), + "id: overlay.translation.da\nkind: translation\ntranslations:\n direct:\n Finland: Suomi\n", + ) + .unwrap(); + fs::write( + dir.join("patch.yaml"), + "id: overlay.patch.country\nkind: patch\nnotes:\n note.finland:\n intent: merge\n fields:\n field.country:\n intent: replace\n value: Suomi\n expected_base:\n value: Suomi\n", + ) + .unwrap(); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + let manifest = manifest + .replacen( + "targets:\n", + " overlay.patch.country:\n file: patch.yaml\n kind: patch\ntargets:\n", + 1, + ) + .replace( + " - overlay.translation.da\n", + " - overlay.translation.da\n - overlay.patch.country\n", + ); + fs::write(dir.join("brainbrew.yaml"), manifest).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let response = ureq::get(&server.url("/api/workbench/note-list?language=da&target=standard")) + .call() + .unwrap_err(); + let response = response.into_response().unwrap(); + assert_eq!(response.status(), 422); + let body: serde_json::Value = serde_json::from_str(&response.into_string().unwrap()).unwrap(); + assert_eq!(body["error"]["schema_version"], 1); + assert_eq!(body["error"]["code"], "overlay_conflict"); + assert_eq!(body["error"]["category"], "conflict"); + let diagnostic = &body["error"]["diagnostics"][0]; + assert_eq!( + diagnostic["path"], + "notes.note.finland.fields.field.country" + ); + assert_eq!(diagnostic["overlay"], "overlay.patch.country"); + assert_eq!(diagnostic["conflict"]["first"], "overlay.translation.da"); + assert_eq!(diagnostic["conflict"]["current"], "overlay.patch.country"); +} + +#[test] +fn workbench_message_reference_and_cycle_failures_keep_field_graph_details() { + for (name, reference, code) in [ + ( + "missing", + "notes.note.missing.fields.field.capital", + "invalid_message_reference", + ), + ( + "cycle", + "notes.note.finland.fields.field.capital", + "message_dependency_cycle", + ), + ] { + let dir = temp_dir(&format!("workbench-message-{name}")); + write_workbench_workspace(&dir); + fs::write( + dir.join("message.yaml"), + format!( + "id: overlay.patch.message\nkind: patch\nnotes:\n note.finland:\n intent: merge\n fields:\n field.capital:\n intent: replace\n message:\n - ref: {reference}\n expected_base:\n value: Helsinki\n" + ), + ) + .unwrap(); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + let manifest = manifest + .replacen( + "targets:\n", + " overlay.patch.message:\n file: message.yaml\n kind: patch\ntargets:\n", + 1, + ) + .replace( + " - overlay.translation.da\n", + " - overlay.translation.da\n - overlay.patch.message\n", + ); + fs::write(dir.join("brainbrew.yaml"), manifest).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let response = + ureq::get(&server.url("/api/workbench/note-list?language=da&target=standard")) + .call() + .unwrap_err() + .into_response() + .unwrap(); + assert_eq!(response.status(), 422); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().unwrap()).unwrap(); + assert_eq!(body["error"]["schema_version"], 1); + let child = &body["error"]["diagnostics"][0]["children"][0]; + assert_eq!(child["code"], code); + assert!( + child["path"] + .as_str() + .unwrap() + .starts_with("notes.note.finland.fields.field.capital") + ); + assert!( + child["field_graph"]["consuming_path"] + .as_str() + .unwrap() + .starts_with("notes.note.finland.fields.field.capital") + ); + if name == "cycle" { + assert!(child["field_graph"]["cycle"].as_array().unwrap().len() >= 2); + } else { + assert_eq!(child["field_graph"]["dependency"], reference); + } + } +} + +#[test] +fn workbench_note_pivot_exposes_target_translation_data_and_media() { + let dir = temp_dir("workbench-note-pivot"); + write_workbench_workspace(&dir); + fs::create_dir_all(dir.join("media/flags")).unwrap(); + fs::write(dir.join("media/flags/fi.png"), b"png").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + assert_eq!(pivot["language"]["code"], "da"); + assert_eq!(pivot["target"]["id"], "da-standard"); + assert_eq!(pivot["overlay"]["id"], "overlay.translation.da"); + assert_eq!(pivot["selection_options"]["languages"][0]["code"], "da"); + assert_eq!( + pivot["selection_options"]["targets"][0]["label"], + "standard" + ); + assert_eq!(pivot["selection_options"]["overlays"][0]["label"], "base"); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 1); + assert_eq!(pivot["progress"]["missing"], 1); + assert_eq!(pivot["overlay_badges"][0]["label"], "base"); + let note = &pivot["notes"][0]; + assert_eq!(note["note_id"], "note.finland"); + assert!( + note["source_preview"]["cards"][0]["question_html"] + .as_str() + .unwrap() + .contains("Finland") + ); + assert!( + note["source_preview"]["cards"][0]["answer_html"] + .as_str() + .unwrap() + .contains("Helsinki") + ); + let capital = note["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap(); + assert_eq!(capital["status"], "untranslated_fallback"); + assert_eq!(capital["occurrence_count"], 1); + assert_eq!(capital["controls"][0], "direct"); + let flag = note["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.flag") + .unwrap(); + assert_eq!(flag["structural"], true); + assert_eq!(flag["editable"], false); + assert_eq!(flag["source_editable"], true); + + let missing = get_json(&server.url("/api/workbench/note-pivot?language=da&filter=missing")); + assert_eq!(missing["notes"].as_array().unwrap().len(), 1); + + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media succeeds"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/png" + ); +} + +#[test] +fn workbench_media_cache_refreshes_after_workspace_edit_and_rejects_undeclared_paths() { + let dir = temp_dir("workbench-media-cache-refresh"); + write_workbench_workspace(&dir); + fs::create_dir_all(dir.join("media/flags")).unwrap(); + fs::write(dir.join("media/flags/fi.png"), b"fi").unwrap(); + fs::write(dir.join("media/flags/se.png"), b"se").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let original = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("initial declared media request warms the cache"); + assert_eq!(original.status(), 200); + + std::thread::sleep(Duration::from_millis(20)); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + fs::write( + dir.join("deck.yaml"), + deck.replace( + " media.flags-fi-png:\n path: flags/fi.png\n sha256: ''", + " media.flags-fi-png:\n path: flags/fi.png\n sha256: ''\n media.flags-se-png:\n path: flags/se.png\n sha256: ''", + ), + ) + .unwrap(); + + let refreshed = ureq::get(&server.url("/api/media/flags/se.png")) + .call() + .expect("newly declared media succeeds after cache invalidation"); + assert_eq!(refreshed.status(), 200); + assert_get_status_contains( + &server.url("/api/media/flags/nope.png"), + 404, + "unknown media asset", + ); +} + +#[test] +fn workbench_media_can_load_from_media_root_or_placeholder() { + let dir = temp_dir("workbench-media-root"); + write_workbench_workspace(&dir); + let external_media = dir.join("external-media"); + fs::create_dir_all(external_media.join("flags")).unwrap(); + fs::write(external_media.join("flags/fi.png"), b"png").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--media-root", + external_media.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media from media root succeeds"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/png" + ); + + let missing_dir = temp_dir("workbench-missing-media-placeholder"); + write_workbench_workspace(&missing_dir); + let missing_server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + missing_dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let placeholder = ureq::get(&missing_server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared missing media returns visible placeholder"); + assert_eq!(placeholder.status(), 200); + assert_eq!( + placeholder.header("content-type").unwrap_or_default(), + "image/svg+xml" + ); + let body = placeholder.into_string().unwrap(); + assert!(body.contains("Missing media asset")); + assert!(body.contains("flags/fi.png")); +} + +#[test] +fn workbench_media_requires_explicit_external_root_selection() { + let root = temp_dir("workbench-auto-external-media"); + let manifest_dir = root.join("projects/deck-fixture"); + fs::create_dir_all(&manifest_dir).unwrap(); + write_workbench_workspace(&manifest_dir); + let external_media = root.join("external/deck-fixture/media/flags"); + fs::create_dir_all(&external_media).unwrap(); + fs::write(external_media.join("fi.png"), b"png").unwrap(); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest_dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media returns a placeholder without an explicit root"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/svg+xml" + ); + assert!(media.into_string().unwrap().contains("Missing media asset")); +} + +#[test] +fn workbench_ug_detail_media_urls_serve_declared_bytes_from_media_root() { + let media_root = temp_dir("workbench-ug-media-diagnostics-root"); + write_ug_media_diagnostics_root(&media_root); + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest.to_str().unwrap(), + "--media-root", + media_root.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let note_detail = get_json( + &server.url("/api/workbench/note-detail?language=de&target=standard¬e=note.abkhazia"), + ); + let card_pivot = get_json(&server.url( + "/api/workbench/card-pivot?language=de&target=standard&card=note.abkhazia%3A%3Atemplate.flag-country", + )); + let mut media_paths = BTreeSet::new(); + collect_api_media_paths(¬e_detail, &mut media_paths); + collect_api_media_paths(&card_pivot, &mut media_paths); + + for expected in ["ug-flag-abkhazia.svg", "ug-map-abkhazia.png"] { + assert!( + media_paths.contains(expected), + "detail APIs did not expose expected media path {expected:?}; extracted paths: {media_paths:?}" + ); + } + assert!( + media_paths.len() >= 2, + "expected several unique UG media paths from detail APIs; extracted paths: {media_paths:?}" + ); + + for media_path in &media_paths { + assert_media_response_serves_declared_bytes(&server, &media_root, media_path); + } +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_optional_metadata_progress_and_apply_are_separate_from_main_fields() { + let dir = temp_dir("workbench-optional-metadata"); + write_workbench_optional_metadata_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da")); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 2); + assert_eq!(pivot["progress"]["stale"], 0); + assert!(pivot["metadata_progress"]["stale"].as_u64().unwrap() >= 1); + + let optional = get_json(&server.url("/api/workbench/metadata?language=da")); + assert_eq!(optional["main_progress"]["complete"], 2); + let optional_items = optional["items"].as_array().unwrap(); + assert!( + optional_items.iter().all(|item| !item["path"] + .as_str() + .unwrap_or_default() + .contains(".adapter_ids.")), + "adapter IDs are identity metadata, not optional translation metadata: {optional_items:?}" + ); + let category_positions = optional_items + .iter() + .enumerate() + .map(|(index, item)| { + ( + item["metadata_category_key"].as_str().unwrap_or_default(), + index, + ) + }) + .collect::>(); + let note_type_position = category_positions + .iter() + .find_map(|(category, index)| (*category == "note-type-name").then_some(*index)) + .unwrap(); + let template_position = category_positions + .iter() + .find_map(|(category, index)| (*category == "card-template-name").then_some(*index)) + .unwrap(); + assert!( + category_positions + .iter() + .all(|(category, _)| *category != "field-label"), + "Anki field names are structural identifiers, not translation metadata: {category_positions:?}" + ); + assert!( + note_type_position < template_position, + "metadata should be grouped in maintainer-friendly category order: {category_positions:?}" + ); + let deck_name = optional_items + .iter() + .find(|item| item["path"] == "deck.name") + .unwrap(); + assert_eq!(deck_name["status"], "stale"); + assert!( + deck_name["warning"] + .as_str() + .unwrap() + .contains("Old Workbench") + ); + + let request = serde_json::json!({ + "language": "da", + "edits": [{ + "kind": "translation", + "path": "deck.name", + "source": "Ultimate Geography", + "value": "Arbejdsbord Røgtest", + "mode": "direct" + }] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["changed_entries"][0]["path"], "deck.name"); + assert!( + preview["file_groups"] + .as_array() + .unwrap() + .iter() + .any(|file| file["file"] == "da.yaml") + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Arbejdsbord Røgtest") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["applied"], true); + assert!( + fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Arbejdsbord Røgtest") + ); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_rejects_invalid_rendered_description_content() { + let dir = temp_dir("workbench-content-validation"); + write_workbench_optional_metadata_workspace(&dir); + let original_overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "edits": [{ + "kind": "translation", + "path": "deck.description", + "source": "A geography deck fixture.", + "value": "

unterminated", + "mode": "direct" + }] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["schema_version"], 1); + assert_eq!(preview["validation"]["ok"], false); + assert!(preview["validation"].get("errors").is_none()); + let diagnostic = &preview["validation"]["diagnostics"][0]; + assert_eq!(diagnostic["code"], "invalid_html_content"); + assert_eq!(diagnostic["category"], "validation"); + assert_eq!(diagnostic["path"], "deck.description"); + assert!( + diagnostic["message"] + .as_str() + .unwrap() + .contains("unclosed HTML tag

") + ); + + let (status, body) = post_json_error(&server.url("/api/workbench/apply"), request); + assert_eq!(status, 422); + let error: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(error["error"]["schema_version"], 1); + assert_eq!(error["error"]["code"], "invalid_html_content"); + assert_eq!(error["error"]["category"], "validation"); + assert_eq!(error["error"]["diagnostics"][0]["path"], "deck.description"); + assert_eq!( + fs::read_to_string(dir.join("da.yaml")).unwrap(), + original_overlay + ); +} + +#[test] +fn workbench_comparison_pane_summarizes_note_source_string_and_card_context() { + let dir = temp_dir("workbench-comparison-pane"); + write_workbench_repeated_source_multi_language_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let comparison = get_json( + &server.url("/api/workbench/comparison-pane?language=nb&target=standard&overlay=base"), + ); + assert_eq!(comparison["language"]["code"], "nb"); + assert_eq!(comparison["target_label"], "standard"); + assert!( + comparison["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|group| group == "Europe") + ); + let strings = comparison["source_string_pivot"]["strings"] + .as_array() + .unwrap(); + let shared = strings + .iter() + .find(|string| string["source"] == "Shared capital") + .unwrap(); + assert_eq!(shared["target_preview"], "Felles hovedstad"); + assert_eq!(shared["occurrence_count"], 2); + assert!(comparison["card_pivot"]["cards"].as_array().unwrap().len() >= 2); + assert!(comparison["note_pivot"]["notes"].as_array().unwrap().len() >= 2); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_rejects_source_edits_when_target_base_is_locked_dependency() { + let root = temp_dir("workbench-locked-base"); + let dependency = root.join("dependency"); + let workspace = root.join("workspace"); + let cache = root.join("cache"); + fs::create_dir_all(&dependency).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write(dependency.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + dependency.join("brainbrew.yaml"), + "package:\n id: example.locked-base\n version: 1.0.0\nbase: deck.yaml\noverlays: {}\ntargets:\n base:\n overlays: []\n", + ) + .unwrap(); + fs::write(workspace.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + workspace.join("da.yaml"), + "id: overlay.translation.da\nkind: translation\ntranslations: {}\n", + ) + .unwrap(); + fs::write( + workspace.join("brainbrew.yaml"), + r#"package: + id: example.workbench-root + version: 1.0.0 + depends_on: + - example.locked-base@1.0.0 +base: deck.yaml +overlays: + overlay.translation.da: + file: da.yaml + kind: translation +targets: + da-standard: + extends: example.locked-base:base + overlays: + - overlay.translation.da + en-standard: + extends: example.locked-base:base + overlays: [] +languages: + da: + display_name: Danish + translation_overlays: + base: overlay.translation.da + primary_target: standard + targets: + standard: da-standard + en: + display_name: English + source: true + primary_target: standard + targets: + standard: en-standard +"#, + ) + .unwrap(); + let lock = run_with_env( + [ + "lock", + "update", + "--lock", + workspace.join("brainbrew.lock").to_str().unwrap(), + "--package", + "example.locked-base", + "--path", + dependency.to_str().unwrap(), + ], + &[("BRAINBREW_CACHE_DIR", cache.to_str().unwrap())], + ); + assert!(lock.status.success(), "stderr: {}", stderr(&lock)); + let original_root = fs::read(workspace.join("deck.yaml")).unwrap(); + let original_dependency = fs::read(dependency.join("deck.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + workspace.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_CACHE_DIR", cache.to_str().unwrap())], + ); + let error = post_json_error( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.country", + "source": "Finland", + "value": "Locked edit", + "scope": "field", + "impact_action": "stale_translation" + }] + }), + ); + assert_eq!(error.0, 403); + assert!( + error.1.contains("read-only for locked source base"), + "{}", + error.1 + ); + assert_eq!( + fs::read(workspace.join("deck.yaml")).unwrap(), + original_root + ); + assert_eq!( + fs::read(dependency.join("deck.yaml")).unwrap(), + original_dependency + ); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_groups_multi_pane_edits_by_file_and_content_group() { + let dir = temp_dir("workbench-multi-pane-apply"); + write_multi_language_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "source", + "path": "notes.note.finland.fields.field.country", + "source": "Finland", + "value": "Finland source", + "scope": "field", + "impact_action": "stale_translation" + }, + { + "kind": "translation", + "language": "da", + "target": "standard", + "overlay": "base", + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors multi", + "mode": "direct" + }, + { + "kind": "translation", + "language": "nb", + "target": "standard", + "overlay": "base", + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsinki norsk", + "mode": "direct" + } + ] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["ok"], true); + let affected = preview["affected_files"].as_array().unwrap(); + assert!(affected.iter().any(|file| file["path"] == "deck.yaml")); + assert!(affected.iter().any(|file| file["path"] == "da.yaml")); + assert!(affected.iter().any(|file| file["path"] == "nb.yaml")); + let groups = preview["file_groups"].as_array().unwrap(); + assert!(groups.iter().any(|group| { + group["file"] == "deck.yaml" + && group["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|content_group| content_group["name"] == "Europe") + })); + assert!(groups.iter().any(|group| group["file"] == "da.yaml")); + assert!(groups.iter().any(|group| group["file"] == "nb.yaml")); + assert!( + !fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("Finland source") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + assert!( + fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("field.country: Finland source") + ); + let da = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(da.contains("Helsinki: Helsingfors multi")); + assert!(da.contains("old_source: Finland")); + assert!(da.contains("new_source: Finland source")); + let nb = fs::read_to_string(dir.join("nb.yaml")).unwrap(); + assert!(nb.contains("Helsinki: Helsinki norsk")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_journal_creation_failure_leaves_all_targets_unchanged() { + let dir = temp_dir("workbench-atomic-validate"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + let original_da = fs::read(dir.join("da.yaml")).unwrap(); + let original_nb = fs::read(dir.join("nb.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_TRANSACTION_FAIL_POINT", "create-journal")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic validation"), + ); + + assert_eq!(error.0, 409); + assert!( + error.1.contains("transaction") || error.1.contains("journal"), + "unexpected error body: {}", + error.1 + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), original_da); + assert_eq!(fs::read(dir.join("nb.yaml")).unwrap(), original_nb); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_staging_failure_leaves_targets_unchanged() { + let dir = temp_dir("workbench-atomic-temp-fail"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + let original_da = fs::read(dir.join("da.yaml")).unwrap(); + let original_nb = fs::read(dir.join("nb.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_TRANSACTION_FAIL_POINT", "stage:1")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic temp"), + ); + + assert_eq!(error.0, 409); + assert!( + error.1.contains("transaction") || error.1.contains("stage"), + "unexpected error body: {}", + error.1 + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), original_da); + assert_eq!(fs::read(dir.join("nb.yaml")).unwrap(), original_nb); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_restart_recovers_interrupted_prepare_before_apply() { + let dir = temp_dir("workbench-transaction-recovery"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + { + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[ + ("BRAINBREW_TRANSACTION_FAIL_POINT", "stage:0"), + ("BRAINBREW_TRANSACTION_FAIL_MODE", "crash"), + ], + ); + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("interrupted prepare"), + ); + assert_eq!(error.0, 409); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + } + assert!( + fs::read_dir(dir.join(".brainbrew-transactions")) + .unwrap() + .any(|entry| entry.unwrap().file_type().unwrap().is_dir()) + ); + + let restarted = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let applied = post_json( + &restarted.url("/api/workbench/apply"), + atomic_multi_file_apply_request("after recovery"), + ); + assert_eq!(applied["applied"], true); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_replace_failure_restores_every_original_file() { + let dir = temp_dir("workbench-transaction-replace-fail"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + let original_da = fs::read(dir.join("da.yaml")).unwrap(); + let original_nb = fs::read(dir.join("nb.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_TRANSACTION_FAIL_POINT", "replace:1")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic rename"), + ); + + assert_eq!(error.0, 409); + assert!( + error.1.contains("restored the original workspace"), + "{}", + error.1 + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), original_da); + assert_eq!(fs::read(dir.join("nb.yaml")).unwrap(), original_nb); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_uses_recoverable_workspace_transaction() { + let dir = temp_dir("workbench-atomic-trace"); + write_multi_language_workbench_workspace(&dir); + let trace_path = dir.join("atomic-trace.log"); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_TRANSACTION_TRACE", trace_path.to_str().unwrap())], + ); + + let applied = post_json( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic trace"), + ); + + assert_eq!(applied["applied"], true); + let trace = fs::read_to_string(trace_path).expect("transaction writes a trace"); + assert!(trace.contains("transaction_begin"), "trace: {trace}"); + assert!(trace.contains("transaction_end"), "trace: {trace}"); + assert!( + fs::read_dir(dir.join(".brainbrew-transactions")) + .unwrap() + .all(|entry| !entry.unwrap().file_type().unwrap().is_dir()), + "completed Apply left a pending journal" + ); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_concurrent_apply_requests_are_serialized() { + let dir = temp_dir("workbench-atomic-concurrent"); + write_multi_language_workbench_workspace(&dir); + let trace_path = dir.join("atomic-concurrent-trace.log"); + let trace_path_text = trace_path.to_str().unwrap().to_owned(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[ + ("BRAINBREW_TRANSACTION_TRACE", trace_path_text.as_str()), + ("BRAINBREW_TRANSACTION_SLEEP_BEFORE_REPLACE_MS", "150"), + ], + ); + let first_url = server.url("/api/workbench/apply"); + let second_url = first_url.clone(); + let first = atomic_translation_apply_request("first serialized"); + let second = atomic_translation_apply_request("second serialized"); + + let first_thread = thread::spawn(move || post_json(&first_url, first)); + let second_thread = thread::spawn(move || post_json(&second_url, second)); + assert_eq!(first_thread.join().unwrap()["applied"], true); + assert_eq!(second_thread.join().unwrap()["applied"], true); + + let trace = fs::read_to_string(trace_path).expect("atomic helper writes a trace"); + let mut active_transactions = 0usize; + for line in trace.lines() { + if line.contains("transaction_begin") { + assert_eq!( + active_transactions, 0, + "concurrent apply transactions overlapped: {trace}" + ); + active_transactions += 1; + } else if line.contains("transaction_end") { + active_transactions = active_transactions + .checked_sub(1) + .expect("transaction_end without transaction_begin"); + } + } + assert_eq!(active_transactions, 0, "unterminated transaction: {trace}"); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_card_pivot_navigates_previews_and_applies_field_edit() { + let dir = temp_dir("workbench-card-pivot"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/card-pivot?language=da&target=standard")); + assert_eq!(pivot["progress"]["total"], 1); + assert_eq!(pivot["progress"]["missing"], 1); + assert_eq!( + pivot["cards"][0]["card_id"], + "note.finland::template.country-capital" + ); + assert_eq!(pivot["cards"][0]["status"], "missing"); + assert_eq!( + pivot["selected_card"]["template_id"], + "template.country-capital" + ); + assert!( + pivot["selected_card"]["source_preview"]["cards"][0]["question_html"] + .as_str() + .unwrap() + .contains("Finland") + ); + assert!( + pivot["selected_card"]["target_preview"]["cards"][0]["answer_html"] + .as_str() + .unwrap() + .contains("Helsinki") + ); + let capital = pivot["selected_card"]["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap(); + assert_eq!(capital["status"], "untranslated_fallback"); + assert_eq!(capital["editable"], true); + + let filtered = get_json(&server.url( + "/api/workbench/card-pivot?language=da&target=standard&filter=missing&content_group=Europe", + )); + assert_eq!(filtered["cards"].as_array().unwrap().len(), 1); + let no_match = get_json( + &server.url("/api/workbench/card-pivot?language=da&target=standard&content_group=Asia"), + ); + assert_eq!(no_match["cards"].as_array().unwrap().len(), 0); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors card", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + assert!( + fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Helsinki: Helsingfors card") + ); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_source_string_pivot_supports_direct_contextual_and_no_change_edits() { + let dir = temp_dir("workbench-source-string"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url( + "/api/workbench/source-string-pivot?language=da&target=standard&source=Shared%20capital", + )); + let shared = pivot["strings"] + .as_array() + .unwrap() + .iter() + .find(|source| source["source"] == "Shared capital") + .unwrap(); + assert_eq!(shared["occurrence_count"], 2); + assert_eq!(shared["direct_applies_to"], 2); + assert_eq!(shared["status"], "complete"); + assert_eq!(pivot["occurrences"].as_array().unwrap().len(), 2); + assert!( + pivot["occurrences"][0]["friendly_context"] + .as_str() + .unwrap() + .contains("Capital") + ); + assert!( + pivot["filters"]["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|group| group == "Europe") + ); + + let preview = post_json( + &server.url("/api/workbench/apply-preview"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Fælles hovedstad opdateret", + "mode": "direct" + }] + }), + ); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["changed_entries"][0]["mode"], "direct"); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("opdateret") + ); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finsk særskilt", + "mode": "contextual", + "context_path": "notes.note.finland.fields.field.capital" + }, + { + "kind": "translation", + "path": "notes.note.estonia.fields.field.country", + "source": "Estonia", + "value": "Estonia", + "mode": "contextual", + "context_path": "notes.note.estonia.fields.field.country" + }, + { + "kind": "translation", + "path": "notes.note.finland.fields.field.country", + "source": "Finland", + "value": "Finland", + "mode": "no_change" + } + ] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("no_change:")); + assert!(overlay.contains("- Finland")); + assert!(overlay.contains("finland.fields.field.capital")); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Finsk særskilt")); + assert!(overlay.contains("estonia.fields.field.country")); + assert!(overlay.contains("Estonia: Estonia")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_source_string_pivot_exposes_structured_message_components() { + let dir = temp_dir("workbench-source-string-structured"); + write_structured_message_translation_workspace(&dir); + fs::write( + dir.join("brainbrew.yaml"), + r#"base: deck.yaml +overlays: + overlay.translation.nb: + file: nb.yaml + kind: translation +targets: + nb-standard: + overlays: + - overlay.translation.nb + en-standard: + overlays: [] +languages: + nb: + display_name: Norwegian Bokmål + translation_overlays: + base: overlay.translation.nb + primary_target: standard + targets: + standard: nb-standard + en: + display_name: English + source: true + primary_target: standard + targets: + standard: en-standard +translation_profile: + structural_fields: + - field.flag + metadata_categories: + - key: deck-metadata + label: Deck metadata + paths: + - deck.name + - deck.description + metadata_paths: + - deck.* +"#, + ) + .unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url( + "/api/workbench/source-string-pivot?language=nb&target=standard&source=blue%20background%20with%20a%20white%20cross", + )); + assert_eq!( + pivot["selected_source"], + "blue background with a white cross" + ); + assert_eq!(pivot["occurrences"].as_array().unwrap().len(), 1); + let occurrence = &pivot["occurrences"][0]; + assert_eq!( + occurrence["path"], + "notes.note.finland.fields.field.flag-similarity.message.2" + ); + assert_eq!(occurrence["target"], "blå bakgrunn med hvitt kors"); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "nb", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "translation", + "path": "notes.note.finland.fields.field.flag-similarity.message.2", + "source": "blue background with a white cross", + "value": "blå bakgrunn med kvitt kors", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("nb.yaml")).unwrap(); + assert!(overlay.contains("blue background with a white cross:")); + assert!(overlay.contains("blå bakgrunn med kvitt kors")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_apply_preview_and_apply_write_translation_overlay() { + let dir = temp_dir("workbench-apply"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "direct" + }] + }); + + let rejected = ureq::post(&server.url("/api/workbench/apply-preview")) + .set("content-type", "application/json") + .send_string( + &serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Bogus", + "value": "Stale", + "mode": "direct" + }] + }) + .to_string(), + ) + .expect_err("stale staged source is rejected"); + match rejected { + ureq::Error::Status(status, response) => { + assert_eq!(status, 400); + assert!(response.into_string().unwrap().contains("invalid source")); + } + other => panic!("unexpected stale-source error: {other}"), + } + + let contextual_preview = post_json( + &server.url("/api/workbench/apply-preview"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "contextual" + }] + }), + ); + assert_eq!(contextual_preview["validation"]["ok"], true); + assert_eq!( + contextual_preview["changed_entries"][0]["path"], + "notes.note.finland" + ); + assert_eq!( + contextual_preview["changed_entries"][0]["field_path"], + "notes.note.finland.fields.field.capital" + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("contextual") + ); + + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["mode"], "preview"); + assert_eq!(preview["applied"], false); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["affected_files"][0]["path"], "da.yaml"); + assert_eq!(preview["changed_entries"][0]["source"], "Helsinki"); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Helsingfors") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["mode"], "write"); + assert_eq!(applied["applied"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Helsinki: Helsingfors")); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + assert_eq!(pivot["progress"]["complete"], 2); + let capital = pivot["notes"][0]["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(capital["target"], "Helsingfors"); + assert_eq!(capital["status"], "direct_translation"); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_source_edits_create_contextual_stale_translations_for_changed_occurrence() { + let dir = temp_dir("workbench-source-stale"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finnish capital", + "scope": "field", + "impact_action": "stale_translation" + }] + }); + + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["mode"], "preview"); + assert_eq!(preview["applied"], false); + assert_eq!(preview["validation"]["ok"], true); + assert!( + preview["affected_files"] + .as_array() + .unwrap() + .iter() + .any(|file| file["path"] == "deck.yaml") + ); + assert!( + preview["affected_files"] + .as_array() + .unwrap() + .iter() + .any(|file| file["path"] == "da.yaml") + ); + assert!( + preview["changed_entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry["mode"] == "stale_translation" + && entry["old_source"] == "Shared capital" + && entry["new_source"] == "Finnish capital" + && entry["target"] == "Fælles hovedstad" + && entry["context"] == "notes.note.finland" + }) + ); + assert!( + !fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("Finnish capital") + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("stale_translations") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["mode"], "write"); + assert_eq!(applied["applied"], true); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + assert!(deck.contains("field.capital: Finnish capital")); + assert!(deck.contains("field.capital: Shared capital")); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(overlay.contains("stale_translations:")); + assert!(overlay.contains("old_source: Shared capital")); + assert!(overlay.contains("new_source: Finnish capital")); + assert!(overlay.contains("target:")); + assert!(overlay.contains("context: notes.note.finland")); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + let finland_capital = pivot["notes"] + .as_array() + .unwrap() + .iter() + .find(|note| note["note_id"] == "note.finland") + .unwrap()["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(finland_capital["source"], "Finnish capital"); + assert_eq!(finland_capital["target"], "Fælles hovedstad"); + assert_eq!(finland_capital["status"], "stale_translation"); + let estonia_capital = pivot["notes"] + .as_array() + .unwrap() + .iter() + .find(|note| note["note_id"] == "note.estonia") + .unwrap()["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(estonia_capital["source"], "Shared capital"); + assert_eq!(estonia_capital["status"], "direct_translation"); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_source_edits_preserve_contextual_impacts_per_occurrence() { + let dir = temp_dir("workbench-source-contextual-stale"); + write_workbench_repeated_source_contextual_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Regional capital", + "scope": "all_occurrences", + "impact_action": "stale_translation" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let stale_entries = applied["changed_entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["mode"] == "stale_translation") + .collect::>(); + assert_eq!(stale_entries.len(), 2); + assert!(stale_entries.iter().any(|entry| { + entry["context"] == "notes.note.finland" && entry["target"] == "Finsk fælles" + })); + assert!(stale_entries.iter().any(|entry| { + entry["context"] == "notes.note.estonia" && entry["target"] == "Estisk fælles" + })); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("stale_translations:")); + assert!(overlay.contains("Finsk fælles")); + assert!(overlay.contains("Estisk fælles")); + assert!(!overlay.contains("contextual:")); + + let dir = temp_dir("workbench-source-contextual-migrate"); + write_workbench_repeated_source_contextual_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Regional capital", + "scope": "all_occurrences", + "impact_action": "migrate_key" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let migrated_entries = applied["changed_entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["mode"] == "migrate_key") + .collect::>(); + assert_eq!(migrated_entries.len(), 2); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Regional capital:")); + assert!(overlay.contains("Finsk fælles")); + assert!(overlay.contains("Estisk fælles")); + assert!(!overlay.contains("Shared capital:")); + assert!(!overlay.contains("stale_translations")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_source_edits_can_migrate_keys_change_all_and_preserve_includes() { + let dir = temp_dir("workbench-source-migrate"); + write_workbench_repeated_source_workspace(&dir); + fs::create_dir_all(dir.join("content")).unwrap(); + fs::write(dir.join("content/finland-capital.txt"), "Shared capital").unwrap(); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap().replacen( + "field.capital: Shared capital", + "field.capital: !include content/finland-capital.txt", + 1, + ); + fs::write(dir.join("deck.yaml"), deck).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Migrated capital", + "scope": "all_occurrences", + "impact_action": "migrate_key" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + assert!(deck.contains("field.capital: !include content/finland-capital.txt")); + assert!(deck.contains("field.capital: Migrated capital")); + let canonical_before = fs::read(dir.join("deck.yaml")).unwrap(); + let formatted = run(["fmt", dir.join("deck.yaml").to_str().unwrap()]); + assert!(formatted.status.success(), "stderr: {}", stderr(&formatted)); + assert_eq!( + fs::read(dir.join("deck.yaml")).unwrap(), + canonical_before, + "Workbench output must already be canonical" + ); + assert_eq!( + fs::read_to_string(dir.join("content/finland-capital.txt")).unwrap(), + "Migrated capital" + ); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Migrated capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(!overlay.contains("Shared capital:")); + assert!(!overlay.contains("stale_translations")); +} + +#[cfg(feature = "workbench-write-dev")] +#[test] +fn workbench_mixed_source_then_translation_apply_uses_new_source_state() { + let dir = temp_dir("workbench-source-mixed"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finnish capital", + "scope": "field", + "impact_action": "stale_translation" + }, + { + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Finnish capital", + "value": "Finsk hovedstad", + "mode": "contextual" + } + ] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(overlay.contains("Finnish capital:")); + assert!(overlay.contains("Finsk hovedstad")); + assert!(!overlay.contains("stale_translations")); +} + +#[test] +fn workbench_serve_can_use_dev_asset_directory() { + let dir = temp_dir("workbench-dev-assets"); + write_workbench_workspace(&dir); + let assets = dir.join("assets"); + fs::create_dir_all(&assets).unwrap(); + fs::write( + assets.join("index.html"), + "

dev workbench shell
", + ) + .unwrap(); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + "--dev-assets", + assets.to_str().unwrap(), + ]); + + let response = ureq::get(&server.url("/")).call().expect("GET / succeeds"); + assert_eq!(response.status(), 200); + assert!( + response + .into_string() + .unwrap() + .contains("dev workbench shell") + ); +} + +#[test] +fn validate_without_args_shows_usage_examples() { + let output = run(["validate"]); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("Usage:")); + assert!(err.contains("Examples:")); + assert!(err.contains("brainbrew validate deck.yaml")); +} + +#[test] +fn validate_reports_valid_deck_human_readably() { + let dir = temp_dir("validate-valid"); + let deck_path = dir.join("deck.yaml"); + fs::write(&deck_path, SAMPLE_CANONICAL_YAML).unwrap(); + + let output = run(["validate", deck_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("valid deck")); + assert!(out.contains(deck_path.to_str().unwrap())); + assert!(out.contains("notes: 1")); +} + +#[test] +fn validate_reports_invalid_deck_path() { + let dir = temp_dir("validate-invalid"); + let deck_path = dir.join("deck.yaml"); + fs::write( + &deck_path, + SAMPLE_CANONICAL_YAML.replace( + "note_type_id: note-type.country", + "note_type_id: note-type.missing", + ), + ) + .unwrap(); + + let output = run(["validate", deck_path.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("notes.note.finland.note_type_id")); +} + +#[test] +fn fmt_rewrites_canonical_yaml_in_place() { + let dir = temp_dir("fmt"); + let deck_path = dir.join("deck.yaml"); + fs::write(&deck_path, MESSY_CANONICAL_YAML).unwrap(); + + let output = run(["fmt", deck_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + fs::read_to_string(deck_path).unwrap(), + SAMPLE_CANONICAL_YAML + ); +} + +#[test] +fn fmt_rewrites_overlay_yaml_in_place() { + let dir = temp_dir("fmt-overlay"); + let overlay_path = dir.join("overlay.yaml"); + fs::write(&overlay_path, MESSY_OVERLAY_YAML).unwrap(); + + let output = run(["fmt", overlay_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + fs::read_to_string(overlay_path).unwrap(), + CAPITAL_OVERLAY_YAML + ); +} + +#[test] +fn fmt_migrates_legacy_target_additions_with_an_actionable_warning() { + let dir = temp_dir("fmt-legacy-target-additions"); + let overlay_path = dir.join("overlay.yaml"); + fs::write( + &overlay_path, + "id: overlay.translation.da\nkind: translation\ntranslations:\n target_additions:\n notes.note.finland.fields.field.capital: Helsingfors\n", + ) + .unwrap(); + + let output = run(["fmt", overlay_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(stderr(&output).contains("legacy target_additions")); + assert!(stderr(&output).contains("brainbrew fmt")); + let formatted = fs::read_to_string(overlay_path).unwrap(); + assert!(formatted.contains("target_adaptations:")); + assert!(formatted.contains("intent: adapt")); + assert!(!formatted.contains("target_additions:")); +} + +#[test] +fn fmt_rewrites_manifest_yaml_in_place() { + let dir = temp_dir("fmt-manifest"); + let manifest_path = dir.join("brainbrew.yaml"); + fs::write(&manifest_path, MESSY_MANIFEST_YAML).unwrap(); + + let output = run(["fmt", manifest_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(fs::read_to_string(manifest_path).unwrap(), MANIFEST_YAML); +} + +#[test] +fn fmt_rewrites_federation_lock_yaml_in_place() { + let dir = temp_dir("fmt-lock"); + let lock_path = dir.join("brainbrew.lock"); + fs::write(&lock_path, MESSY_LOCK_YAML).unwrap(); + + let output = run(["fmt", lock_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(fs::read_to_string(lock_path).unwrap(), LOCK_YAML); +} + +#[test] +fn fmt_rejects_duplicate_dynamic_keys_without_changing_source_bytes() { + let dir = temp_dir("fmt-duplicate-dynamic-keys"); + let cases = [ + ( + "deck.yaml", + SAMPLE_CANONICAL_YAML.replace( + " field.capital: Helsinki\n", + " field.capital: Helsinki\n field.capital: Helsingfors\n", + ), + "notes.note.finland.fields.field.capital", + "field.capital", + ), + ( + "overlay.yaml", + CAPITAL_OVERLAY_YAML.replace( + " field.capital:\n", + " field.capital:\n intent: replace\n value: Oslo\n expected_base:\n value: Helsinki\n field.capital:\n", + ), + "notes.note.finland.fields.field.capital", + "field.capital", + ), + ( + "brainbrew.yaml", + MANIFEST_YAML.replace( + "targets:\n patched-via-dependency:\n", + "targets:\n patched-via-dependency:\n overlays: []\n patched-via-dependency:\n", + ), + "targets.patched-via-dependency", + "patched-via-dependency", + ), + ( + "brainbrew.lock", + LOCK_YAML.replace( + "packages:\n anki-geo.ultimate-geography:\n", + "packages:\n anki-geo.ultimate-geography:\n manifest: duplicate.yaml\n package:\n version: 0.0.0\n locked:\n type: path\n path: duplicate\n anki-geo.ultimate-geography:\n", + ), + "packages.anki-geo.ultimate-geography", + "anki-geo.ultimate-geography", + ), + ( + "media.yaml", + format!("{MEDIA_MAP_YAML}{MEDIA_MAP_YAML}"), + "media.flags-fi-png", + "media.flags-fi-png", + ), + ]; + + for (name, source, schema_path, key) in cases { + let path = dir.join(name); + fs::write(&path, &source).unwrap(); + + let output = run(["fmt", path.to_str().unwrap()]); + + assert!( + !output.status.success(), + "{name}: formatter unexpectedly succeeded" + ); + let error = stderr(&output); + assert!(error.contains(path.to_str().unwrap()), "{name}: {error}"); + assert!(error.contains(schema_path), "{name}: {error}"); + assert!( + error.contains(&format!("duplicate key {key:?}")), + "{name}: {error}" + ); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{name}"); + } +} + +#[test] +fn fmt_rejects_malformed_unions_and_scalars_without_changing_source_bytes() { + let dir = temp_dir("fmt-malformed-yaml-schema"); + let cases = [ + ( + "overlay.yaml", + "id: overlay.strict\nkind: patch\nnotes:\n note.finland:\n intent: merge\n fields:\n field.capital:\n intent: replace\n value: Helsinki\n message:\n - literal: city\n", + "notes.note.finland.fields.field.capital", + ), + ( + "deck.yaml", + "deck:\n id: deck.strict\n name: Strict\n description: true\nnote_types: {}\nnotes: {}\n", + "deck.description", + ), + ( + "media.yaml", + "media.strict:\n path: null\n sha256: hash\n", + "media.strict.path", + ), + ]; + + for (name, source, schema_path) in cases { + let path = dir.join(name); + fs::write(&path, source).unwrap(); + + let output = run(["fmt", path.to_str().unwrap()]); + + assert!( + !output.status.success(), + "{name}: formatter unexpectedly succeeded" + ); + let error = stderr(&output); + assert!(error.contains(path.to_str().unwrap()), "{name}: {error}"); + assert!(error.contains(schema_path), "{name}: {error}"); + assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{name}"); + } +} + +#[test] +fn compose_applies_overlay_files_in_order() { + let dir = temp_dir("compose-overlay"); + let deck_path = dir.join("deck.yaml"); + let overlay_path = dir.join("overlay.yaml"); + let resolved_path = dir.join("resolved.yaml"); + fs::write(&deck_path, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write(&overlay_path, CAPITAL_OVERLAY_YAML).unwrap(); + + let output = run([ + "compose", + deck_path.to_str().unwrap(), + "--overlay", + overlay_path.to_str().unwrap(), + "--out", + resolved_path.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("composed deck")); + assert!(out.contains(resolved_path.to_str().unwrap())); + assert!( + fs::read_to_string(resolved_path) + .unwrap() + .contains("field.capital: Helsingfors") + ); +} + +#[test] +fn targets_lists_manifest_targets() { + let dir = temp_dir("targets-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(stdout(&output), "patched-via-dependency\n"); +} + +#[test] +fn targets_can_discover_multiple_package_manifests() { + let first = temp_dir("targets-package-first"); + let second = temp_dir("targets-package-second"); + write_manifest_workspace(&first); + write_manifest_workspace(&second); + fs::write( + first.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + fs::write( + second.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", "") + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run([ + "targets", + "--manifest", + first.join("brainbrew.yaml").to_str().unwrap(), + "--include", + second.join("brainbrew.yaml").to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("anki-geo.ultimate-geography:patched-via-dependency")); + assert!(out.contains("anki-geo.rivers:rivers")); +} + +#[test] +fn targets_discovers_package_root_and_validates_dependencies() { + let root = temp_dir("targets-package-root"); + let ug = root.join("ultimate-geography"); + let rivers = root.join("rivers"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&ug); + write_manifest_workspace(&rivers); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography@0.1.0", + "depends_on:\n - anki-geo.ultimate-geography@0.1.0", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("anki-geo.ultimate-geography:patched-via-dependency")); + assert!(out.contains("anki-geo.rivers:rivers")); +} + +#[test] +fn compose_can_resolve_extended_targets_from_brainbrew_lock() { + let root = temp_dir("compose-federated-lock"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + fs::write(america.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + america.join("america.yaml"), + r#"id: overlay.extension.america +kind: extension +notes: + note.finland: + intent: merge + tags: + America::Imported: + intent: add +"#, + ) + .unwrap(); + fs::write( + america.join("brainbrew.yaml"), + r#"package: + id: anki-geo.america + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 +base: deck.yaml +overlays: + overlay.extension.america: + file: america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - overlay.extension.america +"#, + ) + .unwrap(); + let lock_path = america.join("brainbrew.lock"); + let cache = root.join("cache"); + let update = run_with_cache( + [ + "lock", + "update", + "--lock", + lock_path.to_str().unwrap(), + "--package", + "anki-geo.ultimate-geography", + "--path", + ug.to_str().unwrap(), + ], + &cache, + ); + assert!(update.status.success(), "stderr: {}", stderr(&update)); + let resolved = root.join("resolved.yaml"); + + let output = run_with_cache( + [ + "compose", + "--manifest", + america.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "en-america", + "--out", + resolved.to_str().unwrap(), + ], + &cache, + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let resolved_source = fs::read_to_string(resolved).unwrap(); + assert!(resolved_source.contains("field.capital: Helsingfors")); + assert!(resolved_source.contains("America::Imported")); +} + +#[test] +fn lock_update_and_verify_path_package_without_nix() { + let root = temp_dir("lock-update-path"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + let lock_path = america.join("brainbrew.lock"); + + let cache = root.join("cache"); + let update = run_with_cache( + [ + "lock", + "update", + "--lock", + lock_path.to_str().unwrap(), + "--package", + "anki-geo.ultimate-geography", + "--path", + ug.to_str().unwrap(), + ], + &cache, + ); + + assert!(update.status.success(), "stderr: {}", stderr(&update)); + let lock_source = fs::read_to_string(&lock_path).unwrap(); + assert!(lock_source.contains("original:\n type: path")); + assert!(lock_source.contains("locked:\n type: path")); + assert!(lock_source.contains("path: ../ultimate-geography")); + assert!(!lock_source.contains(&ug.canonicalize().unwrap().display().to_string())); + assert!(!lock_source.contains("/nix/store/")); + assert!(lock_source.contains("nar_hash: 'sha256-")); + + let verify = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(verify.status.success(), "stderr: {}", stderr(&verify)); + assert!(stdout(&verify).contains("verified 1 locked package")); + + fs::write( + &lock_path, + fs::read_to_string(&lock_path) + .unwrap() + .replace("sha256-", "sha256-bad"), + ) + .unwrap(); + let mismatch = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(!mismatch.status.success()); + assert!(stderr(&mismatch).contains("canonical SRI SHA-256")); +} + +#[test] +fn lock_update_and_verify_tarball_package_without_nix() { + let root = temp_dir("lock-update-tarball"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + let archive_path = root.join("ultimate-geography.tar.gz"); + write_tar_gz(&archive_path, "ultimate-geography", &ug); + let lock_path = america.join("brainbrew.lock"); + let cache = root.join("cache"); + + let update = run_with_cache( + [ + "lock", + "update", + "--lock", + lock_path.to_str().unwrap(), + "--package", + "anki-geo.ultimate-geography", + "--tarball", + &format!("file://{}", archive_path.display()), + ], + &cache, + ); + + assert!(update.status.success(), "stderr: {}", stderr(&update)); + let lock_source = fs::read_to_string(&lock_path).unwrap(); + assert!(lock_source.contains("original:\n type: tarball")); + assert!(lock_source.contains("locked:\n type: tarball")); + assert!(lock_source.contains("nar_hash: 'sha256-")); + + let verify = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(verify.status.success(), "stderr: {}", stderr(&verify)); + assert!(stdout(&verify).contains("verified 1 locked package")); + + let cache_entry = fs::read_dir(cache.join("sources")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + fs::write( + cache_entry.join("brainbrew.yaml"), + "tampered cache manifest\n", + ) + .unwrap(); + let tampered = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + assert!(!tampered.status.success()); + assert!(stderr(&tampered).contains("cached source")); + assert!(stderr(&tampered).contains("nar_hash mismatch")); +} + +#[test] +fn compose_can_extend_targets_and_mix_overlays_from_included_package_manifests() { + let root = temp_dir("compose-federated-package"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + fs::write(america.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + america.join("america.yaml"), + r#"id: overlay.extension.america +kind: extension +notes: + note.finland: + intent: merge + tags: + America::Imported: + intent: add +"#, + ) + .unwrap(); + fs::write( + america.join("brainbrew.yaml"), + r#"package: + id: anki-geo.america + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 +base: deck.yaml +overlays: + overlay.extension.america: + file: america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - overlay.extension.america +"#, + ) + .unwrap(); + let america_manifest = america.join("brainbrew.yaml"); + let ug_manifest = ug.join("brainbrew.yaml"); + let resolved = root.join("resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + america_manifest.to_str().unwrap(), + "--include", + ug_manifest.to_str().unwrap(), + "--target", + "en-america", + "--out", + resolved.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let resolved_source = fs::read_to_string(resolved).unwrap(); + assert!(resolved_source.contains("field.capital: Helsingfors")); + assert!(resolved_source.contains("America::Imported")); + + let mixer = root.join("mixer"); + fs::create_dir_all(&mixer).unwrap(); + fs::write(mixer.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + mixer.join("brainbrew.yaml"), + r#"package: + id: example.mix + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 + - anki-geo.america@0.1.0 +base: deck.yaml +overlays: {} +targets: + en-mixed: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - anki-geo.america:overlay.extension.america +"#, + ) + .unwrap(); + let mixer_manifest = mixer.join("brainbrew.yaml"); + let mixed_resolved = root.join("mixed-resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + mixer_manifest.to_str().unwrap(), + "--include", + ug_manifest.to_str().unwrap(), + "--include", + america_manifest.to_str().unwrap(), + "--target", + "en-mixed", + "--out", + mixed_resolved.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let mixed_source = fs::read_to_string(mixed_resolved).unwrap(); + assert!(mixed_source.contains("field.capital: Helsingfors")); + assert!(mixed_source.contains("America::Imported")); +} + +#[test] +fn targets_reports_missing_package_dependencies() { + let root = temp_dir("targets-missing-package-dep"); + let rivers = root.join("rivers"); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&rivers); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography@0.1.0", + "depends_on:\n - anki-geo.ultimate-geography@0.1.0", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("package dependency anki-geo.ultimate-geography")); +} + +#[test] +fn targets_reports_package_dependency_version_mismatches() { + let root = temp_dir("targets-package-version-mismatch"); + let ug = root.join("ultimate-geography"); + let rivers = root.join("rivers"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&ug); + write_manifest_workspace(&rivers); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography@0.1.0", + "depends_on:\n - anki-geo.ultimate-geography@9.9.9", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("resolved to version 0.1.0")); +} + +#[test] +fn targets_json_includes_package_metadata() { + let dir = temp_dir("targets-package-json"); + write_manifest_workspace(&dir); + fs::write( + dir.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace(" depends_on:\n - anki-geo.shared-geography@0.1.0\n", ""), + ) + .unwrap(); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["package"]["id"], "anki-geo.ultimate-geography"); + assert_eq!(json["package"]["version"], "0.1.0"); +} + +#[test] +fn targets_can_report_json_with_expanded_overlays() { + let dir = temp_dir("targets-json"); + write_manifest_workspace(&dir); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["targets"][0]["name"], "patched-via-dependency"); + assert_eq!(json["targets"][0]["overlays"][0]["id"], "patch.capital"); + assert_eq!( + json["targets"][0]["overlays"][1]["id"], + "noop.after-capital" + ); +} + +#[test] +fn validate_uses_manifest_target() { + let dir = temp_dir("validate-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "validate", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("valid target")); + assert!(out.contains("patched-via-dependency")); +} + +#[test] +fn manifest_target_errors_list_available_targets() { + let dir = temp_dir("missing-target"); + write_manifest_workspace(&dir); + + let output = run([ + "compose", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "missing", + ]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("available targets: patched-via-dependency")); +} + +#[test] +fn compose_uses_manifest_target_dependency_expansion() { + let dir = temp_dir("compose-manifest"); + write_manifest_workspace(&dir); + let resolved_path = dir.join("resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--out", + resolved_path.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("composed target")); + assert!(out.contains("patched-via-dependency")); + assert!(out.contains(resolved_path.to_str().unwrap())); + assert!( + fs::read_to_string(resolved_path) + .unwrap() + .contains("field.capital: Helsingfors") + ); +} + +#[test] +fn export_crowdanki_uses_manifest_target_configured_out() { + let dir = temp_dir("export-manifest-configured-out"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_EXPORTS_YAML).unwrap(); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(dir.join("configured-crowdanki/deck.json").exists()); +} + +#[test] +fn export_crowdanki_defaults_manifest_target_out_to_build_crowdanki_target() { + let dir = temp_dir("export-manifest-default-out"); + write_manifest_workspace(&dir); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!( + dir.join("build/crowdanki/patched-via-dependency/deck.json") + .exists() + ); +} + +#[test] +fn export_crowdanki_uses_manifest_target() { + let dir = temp_dir("export-manifest"); + write_manifest_workspace(&dir); + let export_dir = dir.join("crowdanki"); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + "--out", + export_dir.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!( + fs::read_to_string(export_dir.join("deck.json")) + .unwrap() + .contains("Helsingfors") + ); +} + +#[test] +fn verify_compares_configured_crowdanki_golden() { + let dir = temp_dir("verify-golden"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_EXPORTS_YAML).unwrap(); + let golden_dir = dir.join("goldens/patched"); + + let export_output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + "--out", + golden_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + + let verify_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--media-mode", + "reference-only", + ]); + assert!( + verify_output.status.success(), + "stderr: {}", + stderr(&verify_output) + ); + + let golden_path = golden_dir.join("deck.json"); + fs::write( + &golden_path, + fs::read_to_string(&golden_path) + .unwrap() + .replace("Helsingfors", "Helsinki"), + ) + .unwrap(); + let mismatch_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + ]); + assert!(!mismatch_output.status.success()); + assert!(stderr(&mismatch_output).contains("CrowdAnki golden mismatch")); +} + +#[test] +fn verify_allows_configured_crowdanki_golden_paths() { + let dir = temp_dir("verify-golden-allowlist"); + write_manifest_workspace(&dir); + fs::write( + dir.join("brainbrew.yaml"), + MANIFEST_WITH_EXPORTS_YAML.replace( + " golden: goldens/patched/deck.json\n", + " golden: goldens/patched/deck.json\n golden_allowlist:\n - '$.name'\n", + ), + ) + .unwrap(); + let golden_dir = dir.join("goldens/patched"); + + let export_output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + "--out", + golden_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + + let golden_path = golden_dir.join("deck.json"); + let mut golden_json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); + golden_json["name"] = serde_json::json!("Allowed Legacy Name"); + fs::write( + &golden_path, + serde_json::to_string_pretty(&golden_json).unwrap(), + ) + .unwrap(); + + let verify_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--media-mode", + "reference-only", + ]); + assert!( + verify_output.status.success(), + "stderr: {}", + stderr(&verify_output) + ); +} + +#[test] +fn verify_checks_all_manifest_targets() { + let dir = temp_dir("verify-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--media-mode", + "reference-only", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("verified 1 target")); +} + +#[test] +fn verify_checks_rendered_html_and_css_content_with_escape_hatch() { + let dir = temp_dir("verify-content-validation"); + write_manifest_workspace(&dir); + fs::write( + dir.join("deck.yaml"), + SAMPLE_CANONICAL_YAML.replace( + " styling: |\n .card { font-family: sans-serif; }\n", + " styling: |\n .card { color: red;\n", + ), + ) + .unwrap(); + + let failed = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--media-mode", + "reference-only", + ]); + assert!(!failed.status.success()); + let err = stderr(&failed); + assert!(err.contains("content validation failed for target patched-via-dependency")); + assert!(err.contains("note_types.note-type.country.styling:1: unmatched '{'")); + + let skipped = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--media-mode", + "reference-only", + "--skip-content-validation", + ]); + assert!(skipped.status.success(), "stderr: {}", stderr(&skipped)); +} + +#[test] +fn translate_aliases_run_translation_reports() { + let dir = temp_dir("translate-aliases"); + write_translation_workspace(&dir); + + for command in ["translate", "translation"] { + let output = run([ + command, + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--json", + ]); + + assert!( + output.status.success(), + "{command} stderr: {}", + stderr(&output) + ); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["reports"][0]["target"], "da-standard"); + } +} + +#[test] +fn unknown_translate_like_command_suggests_translations() { + let output = run(["translatons"]); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("unknown command")); + assert!(err.contains("Did you mean:")); + assert!(err.contains("brainbrew translations")); +} + +#[test] +fn translations_missing_manifest_lists_nearby_manifests() { + let dir = temp_dir("translations-missing-manifest"); + let workspace = dir.join("decks/sample"); + fs::create_dir_all(&workspace).unwrap(); + write_translation_workspace(&workspace); + + let output = run_in_dir(["translations", "--no-interactive"], &dir); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("No Brain Brew manifest found at brainbrew.yaml")); + assert!(err.contains("Found possible manifests:")); + assert!(err.contains("decks/sample/brainbrew.yaml")); + assert!(err.contains("brainbrew translations --manifest decks/sample/brainbrew.yaml")); +} + +#[test] +fn translations_help_does_not_advertise_rejected_static_editor_flags() { + let output = run(["translations", "--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(!out.contains("--web-editor")); + assert!(!out.contains("--apply-editor-edits")); + assert!(!out.contains("translator editor")); +} + +#[test] +fn translations_rejects_rejected_static_editor_flags() { + let dir = temp_dir("translations-rejected-static-editor-flags"); + write_translation_workspace(&dir); + + let web = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--web-editor", + "--out", + dir.join("editor.html").to_str().unwrap(), + ]); + assert!(!web.status.success()); + assert!(stderr(&web).contains("unexpected translations argument \"--web-editor\"")); + + let apply = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--apply-editor-edits", + dir.join("edits.json").to_str().unwrap(), + ]); + assert!(!apply.status.success()); + assert!(stderr(&apply).contains("unexpected translations argument \"--apply-editor-edits\"")); +} + +#[test] +fn translations_reports_when_selected_target_has_no_dictionary_overlays() { + let manifest = workspace_root().join("fixtures/ug-style/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "full-demo", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("No translation dictionary coverage reports matched")); + assert!(out.contains("translation.es")); + assert!(out.contains("translation-sv.yaml")); + assert!(out.contains("do not use a `translations:` dictionary")); +} + +#[test] +fn translations_context_view_shows_missing_note_field_card_context() { + let dir = temp_dir("translations-context-missing"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--context", + "--source", + "Sweden", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Translation context for target da-standard language da")); + assert!(out.contains("missing_translation notes.note.sweden.fields.field.country")); + assert!(out.contains("note: note.sweden")); + assert!(out.contains("field: field.country (Country)")); + assert!(out.contains("note fields (source/en | target/da):")); + assert!(out.contains("* field.country (Country)")); + assert!(out.contains("cards: template.country-capital [question]")); + assert!(out.contains("source/en")); + assert!(out.contains("target/da")); + assert!(out.contains("Sweden")); +} + +#[test] +fn translations_context_view_shows_ug_repeated_country_info_occurrences() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "Island of Indonesia.", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("duplicate source group: 3 occurrence(s)")); + assert!(out.contains("note: note.bali")); + assert!(out.contains("note: note.java")); + assert!(out.contains("note: note.sumatra")); + assert!(out.contains("field: field.country-info (Country info)")); + assert!(out.contains("template.capital-country [answer]")); + assert!(out.contains("template.country-capital [question+answer]")); + assert!(out.contains("Island of Indonesia.")); + assert!(out.contains("Isla de Indonesia.")); +} + +#[test] +fn translations_context_view_wraps_long_ug_flag_similarity_context() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "blue background, red and white cross", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("note: note.faroe-islands")); + assert!(out.contains("field: field.flag-similarity (Flag similarity)")); + assert!(out.contains("template.flag-country [answer]")); + assert!(out.contains("Iceland (blue background,")); + assert!(out.contains("Islandia (fondo azul,")); + assert!(out.contains("Noruega")); +} + +#[test] +fn translations_context_view_json_exposes_reusable_context_model() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "Autonomous community of Spain.", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let unit = &json["contexts"][0]["units"][0]; + assert_eq!(unit["status"], "direct_translation"); + assert_eq!(unit["note_id"], "note.canary-islands"); + assert_eq!(unit["field_id"], "field.country-info"); + assert_eq!(unit["field_name"], "Country info"); + assert_eq!(unit["note_fields"][0]["field_id"], "field.country"); + assert_eq!(unit["note_fields"][0]["source"], "Canary Islands"); + assert_eq!(unit["note_fields"][0]["translated"], "Canarias"); + assert_eq!(unit["source"], "Autonomous community of Spain."); + assert_eq!(unit["translated"], "Comunidad autónoma de España."); + let card_templates = unit["card_templates"].as_array().unwrap(); + assert!(card_templates.len() >= 4); + let country_capital = card_templates + .iter() + .find(|card| card["template_id"] == "template.country-capital") + .expect("country-capital card context is present"); + assert!( + country_capital["question_format"] + .as_str() + .unwrap() + .contains("{{Country info}}") + ); + assert!( + country_capital["answer_format"] + .as_str() + .unwrap() + .contains("{{Capital}}") + ); +} + +#[test] +fn translations_context_view_json_shows_structured_message_components() { + let dir = temp_dir("translations-context-structured-message"); + write_structured_message_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "nb-standard", + "--context", + "--source", + "blue background", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let unit = &json["contexts"][0]["units"][0]; + assert_eq!( + unit["path"], + "notes.note.finland.fields.field.flag-similarity.message.2" + ); + assert_eq!(unit["status"], "contextual_translation"); + assert_eq!( + unit["message"]["source"], + "Iceland (blue background with a white cross), Norway (red background with a blue cross)" + ); + assert_eq!( + unit["message"]["translated"], + "Island (blå bakgrunn med hvitt kors), Norge (rød bakgrunn med blått kors)" + ); + let components = unit["message"]["components"].as_array().unwrap(); + assert_eq!(components[0]["kind"], "field_ref"); + assert_eq!( + components[0]["reference"], + "notes.note.iceland.fields.field.country" + ); + assert_eq!(components[0]["source"], "Iceland"); + assert_eq!(components[0]["translated"], "Island"); + assert_eq!(components[2]["kind"], "text"); + assert_eq!( + components[2]["source"], + "blue background with a white cross" + ); + assert_eq!(components[2]["translated"], "blå bakgrunn med hvitt kors"); +} + +#[test] +fn translations_context_apply_uses_existing_translation_apply_path() { + let dir = temp_dir("translations-context-apply"); + write_translation_workspace(&dir); + let overlay_path = dir.join("da.yaml"); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--context", + "--source", + "Sweden", + "--apply", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let updated = fs::read_to_string(overlay_path).unwrap(); + assert!(updated.contains(" Sweden: Sweden\n")); +} + +#[test] +fn translations_context_view_can_filter_duplicate_missing_and_language() { + let dir = temp_dir("translations-context-filters"); + write_translation_workspace(&dir); + let deck_path = dir.join("deck.yaml"); + let deck = fs::read_to_string(&deck_path) + .unwrap() + .replace("field.country: Finland", "field.country: Sweden"); + fs::write(&deck_path, deck).unwrap(); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--language", + "da", + "--context", + "--duplicates", + "--status", + "missing", + "--field", + "field.country", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("missing_translation notes.note.sweden.fields.field.country")); + assert!(out.contains("duplicate source group: 2 occurrence(s)")); + assert!(!out.contains("notes.note.finland.fields.field.capital")); +} + +#[test] +fn translations_summary_exports_compact_counts_by_language() { + let dir = temp_dir("translations-summary"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert!(json.get("reports").is_none()); + let summaries = json["summaries"].as_array().unwrap(); + assert_eq!(summaries.len(), 1); + let row = &summaries[0]; + assert_eq!(row["language"], "da"); + assert_eq!( + row["targets"], + serde_json::json!(["da-release", "da-standard"]) + ); + assert_eq!(row["overlay"], "overlay.translation.da"); + assert_eq!(row["file"], "da.yaml"); + assert_eq!(row["direct_translation"], 1); + assert_eq!(row["contextual_translation"], 1); + assert_eq!(row["no_change"], 0); + assert_eq!(row["target_adaptation"], 1); + assert_eq!(row["variable_translation"], 0); + assert_eq!(row["adapter_id_translation"], 0); + assert_eq!(row["untranslated_fallback"], 2); + assert_eq!(row["missing_text_translation"], 2); + assert_eq!(row["hidden_untranslated_fallback"], 0); + assert_eq!(row["stale_invalid"], 1); +} + +#[test] +fn translations_summary_human_output_uses_aligned_space_columns() { + let dir = temp_dir("translations-summary-human"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!( + !out.contains('\t'), + "summary should not rely on terminal tab stops:\n{out}" + ); + assert!( + out.contains("lang tgt direct"), + "header should use compact padded columns:\n{out}" + ); + assert!( + !out.contains("overlay.translation.da"), + "default summary should keep wide overlay/file columns out of the table:\n{out}" + ); + for line in out.lines().skip(1) { + assert!( + line.chars().count() <= 90, + "default summary line should stay compact, got {} chars:\n{line}\n\n{out}", + line.chars().count() + ); + } + let full = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + "--full", + ]); + assert!(full.status.success(), "stderr: {}", stderr(&full)); + assert!(stdout(&full).contains("overlay.translation.da")); + assert!(stdout(&full).contains("da.yaml")); +} + +#[test] +fn translations_default_report_focuses_on_translatable_note_text() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "da-standard", + "--overlay", + "overlay.translation.da", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("missing text translations: 43")); + assert!(out.contains("intentionally unchanged text: 329")); + assert!(out.contains("hidden structural/media/tag fallbacks:")); + assert!(out.contains("hint: use --full")); + assert!(!out.contains("deck.description source=")); + assert!(!out.contains("notes.note.abkhazia.fields.field.flag")); + assert!(!out.contains("notes.note.abkhazia.fields.field.capital")); + assert!(out.contains("notes.note.andorra.fields.field.flag-similarity.message.format")); +} + +#[test] +fn ultimate_geography_language_overlays_have_no_actionable_missing_text() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--all-targets", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let mut missing = Vec::new(); + for report in json["reports"].as_array().unwrap() { + let file = report["file"].as_str().unwrap(); + if !file.starts_with("overlays/languages/") { + continue; + } + let target = report["target"].as_str().unwrap(); + let overlay = report["overlay"].as_str().unwrap(); + for entry in report["entries"].as_array().unwrap() { + if entry["category"] != "untranslated_fallback" { + continue; + } + let path = entry["path"].as_str().unwrap(); + let source = entry["source"].as_str().unwrap(); + let actionable_note_field = path.starts_with("notes.") + && path.contains(".fields.") + && !path.ends_with(".fields.field.flag") + && !path.ends_with(".fields.field.map") + && !path.ends_with(".message.format") + && !source.trim_start().starts_with("